Introducing the PowerShell App Deployment Toolkit

Updated 23/08/2013

Myself and my good friend Dan Cunningham have been working on an exciting PowerShell project together. Here’s a bit of the blurb:

The PowerShell App Deployment Toolkit provides a set of functions to perform common application deployment tasks and to interact with the user during a deployment. It simplifies the complex scripting challenges of deploying applications in the enterprise, provides a consistent deployment experience and improves installation success rates.

The PowerShell App Deployment Toolkit can be used to replace your WiseScript, VBScript and Batch wrapper scripts with one versatile, re-usable and extensible tool.

For more information and to download the toolkit, visit http://psappdeploytoolkit.com

Here are a few screenshots of the user interface:

User Interface Screenshots

Installation Progress

The installation progress message displays an indeterminate progress ring to indicate an installation is in progress and display status messages to the end user. This is invoked using the “Show-InstallationProgress” function.

image_thumb33

The progress message can be dynamically updated to indicate the stage of the installation or to display custom messages to the user, using the “Show-InstallationProgress” function.

image_thumb32

Installation Welcome Prompt

The application welcome prompt can be used to display applications that need to be closed, an option to defer and a countdown to closing applications automatically. Use the “Show-InstallationWelcome” function to display the prompts shown below.

image_thumb20

Welcome prompt with close programs option and defer option:

image_thumb21

Welcome prompt with close programs options and countdown to automatic closing of applications:

image_thumb22

Welcome prompt with just a defer option:

image_thumb23

Block Application Execution

If the block execution option is enabled (see Show-InstallationWelcome function), the user will be prompted that they cannot launch the specified application(s) while the installation is in progress. The application will be unblocked again once the installation has completed.

image_thumb24

Custom Installation Prompt

A custom prompt with the toolkit branding can be used to display messages and interact with the user using the “Show-InstallationPrompt” function. The title and text is customizable and up to 3 customizable buttons can be included on the prompt, e.g.

image_thumb26

image_thumb27

Installation Restart Prompt

A restart prompt can be displayed with a countdown to automatic restart using the “Show-InstallationRestartPrompt”. Since the restart prompt is executed in a separate PowerShell session, the toolkit will still return the appropriate exit code to the parent process.

image_thumb28

Balloon tip notifications

Balloon tip notifications are displayed in the system tray automatically at the beginning and end of the installation. These can be turned off in the XML configuration.

image_thumb12

image_thumb13

image_thumb14

Advertisement

Convert ConfigMgr Applications to MDT Applications with PowerShell

For those of you who have ever attended one of Johan Arwidmark’s talks or classes on OS deployment, you were probably convinced, like me, to capture your reference image using MDT LiteTouch for deployment with ConfigMgr. There are several advantages to capturing your reference image with LiteTouch (speed, compatibility, delegation, features) and there can also be a few disadvantages. One of those is if you have a thick or fat image, i.e. you have a lot of applications installed in your reference image.

Most of the applications you have in your MDT environment need to be maintained in your Configuration Manager environment as well, so that you can deploy the latest versions to your SCCM clients. So, when the time comes to update your reference image, you need to create all of those applications again in MDT.

Enter the “Convert-CMApptoMDTApp” PowerShell script. This script converts applications created in ConfigMgr 2012 SP1 to MDT applications. It utilises the new Configuration Manager 2012 SP1 and MDT 2012 PowerShell modules.

To create an application in MDT, we need a few bits of information:

Name, ShortName, Version, SoftwareVersion, Publisher, Language, CommmandLine, WorkingDirectory, ApplicationSourcePath, DestinationFolder

These can be retrieved from the the ConfigMgr application using the Get-CMDeploymentType cmdlet. We get the latest revisions by specifying the IsLatest property = True.

The Get-CMDeploymentType cmdlet is a strange fish and I had to play around with it quite a bit to understand the information it returns.

When you run the Get-CMDeploymentType cmdlet using the -ApplicationName parameter, you get back an array of deployment types. Each deployment type has a property called SDMPackageXML, which contains most of the interesting information that we need. Firstly, we need to convert this object from XML using the [Microsoft.ConfigurationManagement.ApplicationManagement.Serialization.SccmSerializer]::DeserializeFromString() method so that we can easily access its properties.

Now, each SDMPackageXML object has a property called DeploymentTypes. If you have an application with one Deployment type, the DeploymentTypes property will return information about that one deployment type. However, things start to get a little confusing when you have multiple deployment types for an application.

Let me explain.

1. You have an Application called App1, with a deployment type DT1, which has a version of V1. You run the Get-CMDeploymentType cmdlet. It returns one deployment type. The DeploymentTypes property of that deployment types returns information about DT1 v1.

2. You add a second deployment type to App1, called DT2 v1. You run the Get-CMDeploymentType cmdlet. It returns two deployment types. The DeploymentTypes property of DT1 returns information about DT1. The DeploymentTypes property of DT2 returns information about DT1 v1 and DT2 v1.

3. You make a change to deployment type DT1 v1 – it is now v2. You run the Get-CMDeploymentType cmdlet. It returns two deployment types. The DeploymentTypes property of DT1 returns information about DT1 v2 and DT2 v1. The DeploymentTypes property of DT2 returns information about DT1 v1 and DT2 v1.

So, to summarise the behaviour of the cmdlet when there are multiple deployment types: The most recently modified deployment type contains the most up-to-date information about all deployment types for that application, while less recently modified deployment types contain information about themselves and the versions of other deployment types that existed when they were modified. Weird, huh?

So, to make sure we look at the most recent version of a deployment type in the DeploymentTypes property for that specific deployment type, we need to compare the ID and version of the deployment type using the CI_UniqueID property and the DeploymentTypes.ID property:


Compare-Object -ReferenceObject $dtUniqueID -DifferenceObject $dtInstance.ID -IncludeEqual -Property Name,Version | Where { $_.SideIndicator -eq "==" }

Another issue I encountered is that MDT won’t create applications with the same name or destination folder, but multiple deployment types can share the same application name and different applications might have deployments types with the same name. So we need to append a number to each duplicate application name to ensure they are unique.

Here’s a nice one-liner that takes care of this problem:


$cmAppDT | Group-Object -Property Name -ErrorAction SilentlyContinue | Where { Foreach-object { $_.count -gt 1; $i=1} } -ErrorAction SilentlyContinue  | Select Group -ExpandProperty Group -ErrorAction SilentlyContinue | Foreach-object { Add-Member -InputObject $_ -membertype NoteProperty -name Name -value ($_.Name + "_" + $i) -Force -ErrorAction SilentlyContinue; Add-Member -InputObject $_ -membertype NoteProperty -name DestinationFolder -value ($_.DestinationFolder + "_" + $i) -Force -ErrorAction SilentlyContinue; $i++  }

If you have two deployment types that share the same application name “Application”, these will be renamed Application_1 and Application_2.

Now that we have those small details out of the way, let’s see the script in action.

The script will get a list of all of your CM12 applications and display the list using the Out-GridView cmdlet. This lets you select the applications you want to convert.

CMApps

Once you have selected the applications for conversion, the script runs the Get-CMDeploymentType cmdlet against each one and displays a progress indicator. This can take some time, depending on how many applications you have selected.

DT1

It will only look at MSI and Script deployment types, extract all the necessary information to create an application in MDT and then display the list of deployment types, again using the Out-GridView cmdlet. This lets you select the deployment types you want to convert.

MDTApps

Once you have selected the deployment types for conversion, they are passed to the Import-MDTApplication cmdlet to be created in MDT.

MDTImport

Script Pre-requisites:

Appropriate permissions in ConfigMgr and MDT.
The Configuration Manager 2012 SP1 PowerShell Module is expected in this directory:
“C:\Program Files (x86)\Microsoft Configuration Manager\AdminConsole\bin\ConfigurationManager.psd1”
The MDT 2012 PowerShell Module is expected in this directory:
“C:\Program Files\Microsoft Deployment Toolkit\bin\MicrosoftDeploymentToolkit.psd1”

Running the script:

To run the script, save it as “Convert-CMApptoMDTApp.ps1”. Launch a PowerShell (x86) console as administrator. You can set the values of the parameters in the script, or you can run the script with parameters, e.g.

Convert-CMApptoMDTApp.ps1 -CMSiteCode “CM1” -CMSiteServer “CMServer1” -MDTSharePath “\\MDTServer1\MDTShare” -MDTAppFolderName “CMApps”

The script:


Param (
    [ValidateNotNullOrEmpty()]
    [string]$CMSiteCode = "CM1",
    [ValidateNotNullOrEmpty()]
    [string]$CMSiteServer = "CMServer",
    [ValidateNotNullOrEmpty()]
    [string]$MDTSharePath = "\\MDTServer\MDTShare",
    [ValidateNotNullOrEmpty()]
    [string]$MDTAppFolderName = "CMApps"
)

# Import CM12 and MDT 2012 PowerShell modules
Import-Module "C:\Program Files (x86)\Microsoft Configuration Manager\AdminConsole\bin\ConfigurationManager.psd1"
Import-Module "C:\Program Files\Microsoft Deployment Toolkit\bin\MicrosoftDeploymentToolkit.psd1"

# Set the working directory to the CM Site code
CD ($cmSiteCode + ":")

# Get a list of all CM applications
$cmApps = Get-WmiObject -ComputerName $cmSiteServer -Namespace "Root\SMS\Site_$cmSiteCode" -Query "Select LocalizedDisplayName from SMS_Application Where IsLatest='True'" | Sort LocalizedDisplayName
# Output the list of applications to a Grid to allow browsing and selecting of the CM Apps to be converted to MDT Apps.
$cmApps = $cmApps | Out-GridView -PassThru

# Array to hold all of the CM deployment types
$cmAppDT = @()
# Counter for the progress of CM App processing.
$cmAppIndicator = 0
# Counter for the progress of conversion to MDT Apps.
$mdtAppIndicator = 0

If ($cmApps -ne $null) {
    Foreach ($cmApp in $cmApps) {
        $cmAppsCount = @($cmApps).Count
        $cmAppIndicator++
        Write-Progress -Activity "Processing Application $cmAppIndicator of $cmAppsCount" -Status $cmApp.LocalizedDisplayName -PercentComplete ($cmAppIndicator / $cmAppsCount * 100)

        # Get a list of the deployment types for each application
        $cmDeploymentType = Get-CMDeploymentType -ApplicationName ($cmApp | Select LocalizedDisplayName -ExpandProperty LocalizedDisplayName)

        # Enumerate the latest deployment types and get the latest SDMPackageVersion
        Foreach ($dt in $cmDeploymentType | Where { $_.IsLatest -eq $true }) {
            $SDMPackageXML = $dt | Select SDMPackageXML -ExpandProperty SDMPackageXML
            If ($dt.Technology -match "Script" -or $dt.Technology -match "MSI") {
                If ($SDMPackageXML -ne "") {
                    $dtInfo = [Microsoft.ConfigurationManagement.ApplicationManagement.Serialization.SccmSerializer]::DeserializeFromString($SDMPackageXML)
                    $dtCI_UniqueID = $dt.CI_UniqueID -split "/"
                    $dtUniqueID = @()
                    # Get the Deployment Type ID and version
                    $dtUniqueID = New-Object PSObject -Property @{
                        Name     =    $dtCI_UniqueID[1]
                        Version =    $dtCI_UniqueID[2]
                    }
                    $dtInstances = $dtInfo.DeploymentTypes
                    Foreach ($dtInstance in $dtInstances) {
                        # Compare the DT ID and Version to those contained in the DeploymentTypes property to make sure we get the most recent version of the deployment type and only this deployment type in our ForEach loop.
                        If ((Compare-Object -ReferenceObject $dtUniqueID -DifferenceObject $dtInstance.ID -IncludeEqual -Property Name,Version | Where { $_.SideIndicator -eq "==" } ) -ne $null ) {
                            $dtInstaller = $dtInstance | Select Installer -ExpandProperty Installer
                            If ($dtInstaller.Technology -match "Script" -or $dtInstaller.Technology -match "MSI") {
                                # If the working directory of the CM App is a local drive or environment variable, set the MDT App working directory accordingly
                                If ($dtInstaller.InstallFolder -match ":" -or $dtInstaller.InstallFolder -match "%") {
                                    $dtWorkingDirectory = $dtInstaller.InstallFolder
                                }
                                # Otherwise, set the MDT working directory to the root of the MDT application we are creating.
                                Else {
                                    $dtWorkingDirectory = ".\Applications\" + $dtInfo.Title
                                }
                                # Create a custom PS object with the information from the CM App DT we need to create the MDT App
                                $cmAppDT += New-Object PSObject -Property @{
                                    Name                    =     $dtInfo.Title
                                    ShortName                =     $dtInstance.Title
                                    Version                    =     $dtInfo.SoftwareVersion
                                    Publisher                 =     $dtInfo.Publisher
                                    Language                 =    ($dtInstance | Select Languages -ExpandProperty Languages) -join ","
                                    CommandLine             =    $dtInstaller.InstallCommandLine
                                    WorkingDirectory         =    $dtWorkingDirectory
                                    ApplicationSourcePath    =    ($dtInstaller | Select Contents -ExpandProperty Contents | Select Location -ExpandProperty Location)
                                    DestinationFolder         =    $dtInfo.Title
                                }
                            }
                        }
                    }
                }
                Else {
                    $dtName = $dt.LocalizedDisplayName
                    Write-Host "$dtName has no SDMPackage information"
                }
            }
        }
    }

    If ($cmAppDT -ne $null) {

        # Multiple deployment types can share the same application name and different applications might have deployments types with the same name.
        # MDT won't allow applications with the same name or destination folder, so we need to append a number to each duplicate deployment type to ensure they are unique.
        $cmAppDT | Group-Object -Property Name -ErrorAction SilentlyContinue | Where { Foreach-object { $_.count -gt 1; $i=1} } -ErrorAction SilentlyContinue  | Select Group -ExpandProperty Group -ErrorAction SilentlyContinue | Foreach-object { Add-Member -InputObject $_ -membertype NoteProperty -name Name -value ($_.Name + "_" + $i) -Force -ErrorAction SilentlyContinue; Add-Member -InputObject $_ -membertype NoteProperty -name DestinationFolder -value ($_.DestinationFolder + "_" + $i) -Force -ErrorAction SilentlyContinue; $i++  }

        # Output the Deployment Types to a Grid to allow browsing and selecting of the CM Apps to be converted to MDT Apps.
        $cmAppDTsToConvertToMDTApps = $cmAppDT | Sort -Property Name | Out-GridView -PassThru

        If ($cmAppDTsToConvertToMDTApps -ne $null) {
            $mdtAppCount = @($cmAppDTsToConvertToMDTApps).Count
            # Create a new PSDrive pointing to the MDT share
            New-PSDrive -Name "DS001" -PSProvider MDTProvider -Root $mdtSharePath | Out-Null
            # Create a folder under the MDT Applications folder for our imported CM applications.
            If (!(Test-Path (Join-Path "DS001:\Applications" $MDTAppFolderName))) {
                New-Item -path "DS001:\Applications" -Enable "True" -Name $MDTAppFolderName -Comments "Applications Converted From System Center Configuration Manager" -ItemType Folder | Out-Null
            }
            # Import each selected application in to MDT
            Foreach ($mdtApp in $cmAppDTsToConvertToMDTApps ) {
                $mdtAppIndicator++
                Write-Progress -Activity "Processing Application $mdtAppIndicator of $mdtAppCount" -Status $mdtApp.Name -PercentComplete ($mdtAppIndicator / $mdtAppCount * 100)
                Import-MDTApplication -path "DS001:\Applications\$MDTAppFolderName" -enable "True" -Name $mdtApp.Name -ShortName $mdtApp.ShortName -Version $mdtApp.Version -Publisher $mdtApp.Publisher -Language $mdtApp.Language -CommandLine $mdtApp.CommandLine -WorkingDirectory $mdtApp.WorkingDirectory -ApplicationSourcePath $mdtApp.ApplicationSourcePath -DestinationFolder $mdtApp.DestinationFolder
            }
        }
    }
}

SCCM 2012: Set Software Center Business Hours with a Compliance Configuration Item

There are basically two scenarios when business hours come in to effect in SCCM 2012:

1. A user selects to defer required application/software update installations and reboots to take place outside of business hours:

image

2. A user selects the option in the software center to have SCCM automatically install all required software and restart the computer outside of business hours:image

The default business hours are set to 05:00 – 22:00, Monday to Friday. The business hours can’t be configured through client settings, only by the user or by using the SDK or client WMI methods.

There could be several reasons why you want to change the default business hours. For example, the typical working hours of the majority of your users are different from the default settings and you have a power plan that puts machines in standby before the default business hours end, which would effectively close the window for maintenance outside business hours.

To set the business hours using Compliance settings in ConfigrMgr 2012, navigate to the Asset & Compliance Workspace, select Compliance Settings and create a new configuration item. In the Create Settings page, select a Script setting with a String data type:

image

Edit the Discovery Script and paste in the following Powershell code:

$cmClientUserSettings = [WmiClass]"\\.\ROOT\ccm\ClientSDK:CCM_ClientUXSettings"
$businessHours = $cmClientUserSettings.GetBusinessHours()
$businessHoursCI = [string]$businessHours.StartTime + "," + [string]$businessHours.EndTime + "," + [string]$businessHours.WorkingDays
Return $businessHoursCI

This will return the current business hours as a string in this format:

7,19,62

The first digit is the start time (7am), the second digit is the end time (7pm) and the third digit is the days of the week. The days of the week are calculated using the table below, so Monday – Friday is calculated as 2+4+8+16+32 = 62.

Sunday 1
Monday 2
Tuesday 4
Wednesday 8
Thursday 16
Friday 32
Saturday 64

Edit the Remediation Script and paste in the following code, setting the desired business hours in the variables $startTime, $endTime and $workingDays.


$startTime = 7
$endTime = 19
$workingDays = 62
$cmClientUserSettings = [WmiClass]"\\.\ROOT\ccm\ClientSDK:CCM_ClientUXSettings"
$businessHours = $cmClientUserSettings.PSBase.GetMethodParameters("SetBusinessHours")
$businessHours.StartTime = $StartTime
$businessHours.EndTime = $EndTime
$businessHours.WorkingDays = $WorkingDays

Try {
 $result = $cmClientUserSettings.PSBase.InvokeMethod("SetBusinessHours", $businessHours, $Null)
 If ($result.ReturnValue -eq 0 ) {
 "Success."
 }
 Else {
 "Failed to set SCCM client business hours."
 }
}
Catch {
 "Failed to set SCCM client business hours."
}

Add the following compliance rule, setting the value to your desired business hours and enabling the remediation script for noncompliant clients:

image

This rule will remediate noncompliant clients to the desired business hours.

Lastly, add the configuration item to a configuration baseline and deploy to your target collection.

Now we have centrally controlled business hours in ConfigMgr 2012:

image

Further Reference:

There’s a useful post on Technet that explains the concept of Business Hours vs. Maintenance Windows in System Center 2012 Configuration Manager.

Torsten Merringer has a post here that shows how to set the business hours using VBScript.

MDT UDI Task Sequence: Restore USMT migration using Computer Association Recovery Key

Problem: You need to restore user migration data from the State Migration Point to a computer other than the destination computer specified in the Computer Assocation in the SCCM database.

Some of the possible solutions to this are:

1. Use the Windows Easy Transfer Wizard to open the .Mig file, enter the the recovery key and restore the user data. This requires NTFS read permissions to the SMP share, administrator rights on the local machine and the Windows Easy Transfer Wizard (built in to Windows 7 and 8, separate install on XP).

2. Run the loadstate command with the appropriate parameters on the local machine to restore the data. This requires NTFS read permissions to the SMP share, administrator rights on the local machine, a copy of the USMT source files, and building a pretty long command line, which is not very practical.

Solution: The solution I used was to build an SCCM/MDT task sequence that uses the UDI Wizard to prompt the technician for the recovery information (state store location and state recovery key), authenticate to the SMP share with a service account and run the loadstate command to restore the data on to the machine. Some of the advantages of this method are:

  • A service account is used to access the SMP share, so you don’t have to grant permissions to technicians/users.
  • The task sequence is run in the system context, so local administrator rights are not needed on the client.
  • You can specify a consistent loadstate command with logging.
  • The process is overall more secure, robust, traceable and easy to use.

This solution uses the “Build Your Own Page” feature in the UDI Wizard included in MDT 2012 Update 1.

Step 1 – Create the Service Account

Create a domain account to be used as a service account by the task sequence to access the state migration point. Grant the account NTFS read permission to the SMP share on your SCCM server.

Step 2 – Create the UDI Wizard Page

Using the UDI Wizard Designer, create a new file. Select a StageGroup (I chose Refresh). Click on each page and select “Remove Item”. You can leave a Welcome page and a Summary page if desired.

Select “Add Page” > “Build Your Own Page”. Use the DisplayName “USMT Recovery Page” and the Page Name “USMTRecoveryPage”.

Create a Label: “Enter the user state store location:”
Create a Label: “Enter the user state recovery key:”

Create a TextBox with the variable name “StateStoreLocation” and friendly name “State Store Location”. Assign a “NonEmpty” validator and appropriate message:

Build2

Create a TextBox with the variable name “RecoveryKey” and friendly name “RecoveryKey”. Assign a “NonEmpty” validator and appropriate message:

Build3

You should now have only the USMT Recovery Page and an optional Welcome/Summary Page:

Build4

Save the Wizard page as “UDIWizard_Config_StateMigrationRecovery.xml”

Step 3 – Create the Task Sequence

From the SCCM console Task Sequences node, select “Create MDT Task Sequence”.

Select “Microsoft Deployment Custom Task Sequence”.

Follow the wizard and select the packages to use in the task sequence. We will discard most of the default steps to create a custom task sequence, so it’s not important what options you choose in the wizard.

When the wizard is complete, delete the steps except those you can use in the task sequence below.

TS1

The Toolkit Package should point to your MDT 2012 Update 1 folder with the custom UDI wizard page you created.

The Gather step should point to a customsettings.ini file. In your customsettings.ini file, make sure the deployment type matches the stage you used to create the Wizard page, e.g. “DeploymentType=Refresh”

Add a “Run Command Line” step with the name “UDI Wizard” and the following command line:

cscript.exe “%DeployRoot%\Scripts\UDIWizard.wsf” /definition:UDIWizard_Config_StateMigrationRecovery.xml

Add a “Run Command Line” step with the name “Connect to Network Folder”. Use the variable %StateStoreLocation% for the path and select the service account you created above.

TS2

Add a “Run Command Line” step with the name “Restore User Data”. Check the box to use the USMT package and enter the following command line (substitute your own loadstate options where applicable):

%PROCESSOR_ARCHITECTURE%\loadstate.exe %StateStoreLocation% /ue:*\* /ui:domain\* /v:5 /c /l:%logpath%\loadstate.log /progress:%logpath%\loadstateprogress.log /decrypt /key:”%RecoveryKey%” /i:”%PROCESSOR_ARCHITECTURE%\MigUser.xml” /i:”%PROCESSOR_ARCHITECTURE%\MigApp.xml” /config:”%PROCESSOR_ARCHITECTURE%\Config.xml”

The values of the %StateStoreLocation% and %RecoveryKey% variables are picked up from the fields you populate in the UDI wizard.

Step 4 – Deploy the Task Sequence to a collection

Deploy the task sequence to an appropriate collection. Use the task sequence to restore user migration data from an existing computer association to a different computer destination. When prompted by the UDI Wizard, copy and paste the recovery information from the computer association in the “User State Migration” node in SCCM.

Practical Note: Typing out the state store location and state recovery key is not an option – the state store location can be over 100 characters and the recovery key is 256 characters. The easiest and most appropriate ways to populate the fields in the UDI Wizard would probably be to use the ConfigMgr Remote Control Viewer and copy/paste, or to send an (encrypted) email to the user with the recovery data and copy/paste.

SCCM 2012: Execute task sequence with PowerShell

Here is a PowerShell script to initiate a task sequence with PowerShell. The script takes one argument – the name of the task sequence. The task sequence must be deployed/advertised to the machine already. It searches the deployed task sequences on the machine and if it finds a task sequence matching the one you specified, it will execute it.

Executing a task sequence this way bypasses the prompt to install a new operating system, which may be useful if you have a task sequence that just performs a user capture or restore, or if you use a task sequence for purposes other than OSD and you don’t want the “install a new operating system” warning dialog.

Install Operating System Warning Dialog

You could potentially use this script to deploy task sequences from the ConfigMgr 2012 Application Catalog. This would give you the benefits of the application model such as dependency and requirement rules as well as the benefits of the Application Catalog with its request/approval system. You could then use the Application Catalog as a request/approval portal for OSD refresh scenarios, something that usually requires a third party tool such as SCCM Expert.

In order to make this work, you would need to deploy an application that runs a script which does the following:

  1. Add the machine to a collection that has the task sequence deployed to it.
  2. Trigger a machine policy evaluation.
  3. Trigger the task sequence.

The application deployment type would need a detection rule to indicate that the task sequence has executed successfully so that the application installation doesn’t interfere with the task sequence. The detection rule could vary depending on what the task sequence does.

I haven’t tried the above and one of the potential issues would be granting the client/script permission to add a machine to a collection. Who knows, maybe Microsoft has plans to allow task sequences to be published to the Application Catalog in a future service pack for ConfigMgr 2012.

I have tested this script with ConfigMgr 2012, but it should with with ConfigMgr 2007 also.


Function Execute-TaskSequence {
    Param (
        [parameter(Mandatory = $true)]
        [string]$Name
    )
    Try {
        Write-Host "Connecting to the SCCM client Software Center..."
        $softwareCenter = New-Object -ComObject "UIResource.UIResourceMgr"
    }
    Catch {
        Throw "Could not connect to the client Software Center."
    }
    If ($softwareCenter) {
        Write-Host "Searching for deployments for task sequence [$name]..."
        $taskSequence = $softwareCenter.GetAvailableApplications() | Where { $_.PackageName -eq "$Name" }
        If ($taskSequence) {
            $taskSequenceProgramID = $taskSequence.ID
            $taskSequencePackageID = $taskSequence.PackageID
            Write-Host "Found task sequence [$name] with package ID [$taskSequencePackageID]."
            # Execute the task sequence
            Try {
                Write-Host "Executing task sequence [$name]..."
                $softwareCenter.ExecuteProgram($taskSequenceProgramID,$taskSequencePackageID,$true)
                Write-Host "Task Sequence executed."
            }
            Catch {
                Throw "Failed to execute the task sequence [$name]"
            }
        }
    }
}

SCCM 2012: Target Software Update Deployments to user’s devices based on UDA

While SCCM 2012 provides user-centric application deployment, it is only possible to deploy software updates to a device or device collection.

Here’s a collection I built in SCCM 2012 to retrieve the primary device of a user who is a member of the “SCCM Software Updates Pilot User Group”. In this way, I can deploy software updates to a device collection based on an Active Directory user group. It’s kind of like user-centric software update deployment.

The query below should create a collection of devices where the device has a primary user that is a member of the AD group “SCCM Software Updates Pilot User Group”.

Select SMS_R_SYSTEM.ResourceID,SMS_R_SYSTEM.ResourceType,SMS_R_SYSTEM.Name,SMS_R_SYSTEM.SMSUniqueIdentifier,SMS_R_SYSTEM.ResourceDomainORWorkgroup,SMS_R_SYSTEM.Client FROM SMS_R_System JOIN SMS_UserMachineRelationship ON SMS_R_System.Name=SMS_UserMachineRelationship.ResourceName JOIN SMS_R_User ON SMS_UserMachineRelationship.UniqueUserName=SMS_R_User.UniqueUserName WHERE SMS_UserMachineRelationship.Types=1 AND SMS_R_User.UserGroupName="DOMAIN\\SCCM Software Updates Pilot User Group"

Powershell: Prompt user to close running applications

Update: The PowerShell Application Deployment Toolkit provides this functionality and a lot more, including the ability to prevent users from launching applications while an installation is in progress and optionally allow the user to defer the installation X number of times, X number of days or until a deadline. It also provides a nice UI which you can customize with your own text and logo/banner. Check it out here: http://psappdeploytoolkit.codeplex.com

Recently I needed to write a PowerShell script that required that certain processes were not running. The script needed to be run on workstations, so I had to provide some user interaction to prompt the user to close the running applications.

Below is the snippet of code I used. The script checks to see if certain processes are running and prompts the user to close the applications. A simple message box with and exclamation mark and OK button is used.

MessageBox1

Update: 28/09/2011 – I have updated the script to display a balloon tip notification that the application(s) can be used again, instead of using a second message box prompt that the user would need to acknowledge. Thanks to Robert Robelo for this function: http://robertrobelo.wordpress.com/2010/03/19/balloon-tip-notifications/

Once the script has finished processing, a balloon tip notification is displayed notifying the user that the applications that were closed can now be used again.

Balloon

The message box can be customized using the parameters documented here:

http://msdn.microsoft.com/en-us/library/x83z1d9f(v=vs.85).aspx

For example, to display a message box with the Information Mark icon (64) and OK & Cancel Buttons (1), the “PromptType” parameter should be the sum of these values, i.e. 65. Note that a Cancel button won’t have any effect in the script below, since the user response is not evaluated, only the running processes are evaluated.

# Function to create a MessageBox prompt.
# Arguments: PromptText,PromptWaitTime(Seconds to Wait),PromptTitle,PromptType - See here: http://msdn.microsoft.com/en-us/library/x83z1d9f(v=vs.85).aspx
Function New-Prompt {
	Param (
	[string]$PromptText,
	[int]$PromptWaitTime,
	[string]$PromptTitle,
	[int]$PromptType
	)

	# Create a shell object and invoke a popup prompt
	$promptShell = New-Object -ComObject WScript.Shell		
	$promptAnswer = $promptShell.popup($promptText,$promptWaitTime,$promptTitle,$promptType)
}

# Function to create a balloon tip notification
Function Show-BalloonTip {
	Param(
	[Parameter(Mandatory = $true, Position = 0)]
	[ValidateNotNull()]
	[String]
	$BalloonTipText,
	[Parameter(Position = 1)]
	[String]
	$BalloonTipTitle = 'PowerShell Event Notificaton',
	[Parameter(Position = 2)]
	[ValidateSet('Error', 'Info', 'None', 'Warning')]
	[String]
	$BalloonTipIcon = 'Info',
	[Parameter(Position = 3)]
	[Int]
	$BalloonTipTime = 1000
	)
	end {
		Add-Type -AssemblyName System.Windows.Forms
		Add-Type -AssemblyName System.Drawing
		[Windows.Forms.ToolTipIcon]$BalloonTipIcon = $BalloonTipIcon
		$NotifyIcon = New-Object Windows.Forms.NotifyIcon -Property @{
			BalloonTipIcon = $BalloonTipIcon
			BalloonTipText = $BalloonTipText
			BalloonTipTitle = $BalloonTipTitle
			Icon = [Drawing.Icon]::ExtractAssociatedIcon((Get-Command powershell).Path)
			Text = -join $BalloonTipText[0..62]
			Visible = $true
		}
		switch ($BalloonTipIcon) {
			Error {[Media.SystemSounds]::Hand.Play()}
			Info {[Media.SystemSounds]::Asterisk.Play()}
			None {[Media.SystemSounds]::Beep.Play()}
			Warning {[Media.SystemSounds]::Exclamation.Play()}
		}
		$NotifyIcon.ShowBalloonTip($BalloonTipTime)		
		switch ($Host.Runspace.ApartmentState) {
			STA {
				$null = Register-ObjectEvent -InputObject $NotifyIcon -EventName BalloonTipClosed -Action { 					
					$Sender.Dispose()
					Unregister-Event $EventSubscriber.SourceIdentifier
					Remove-Job $EventSubscriber.Action
				}
			}
			default {
				continue
			}
		}
	}
}

# Function to check for running applications and prompt user to close them. 
Function Stop-RunningApplications {
	Param(
	[parameter(Mandatory = $true)]
	[string]$ProcessName # Specify process names separated by commas 
	)		
	# Split multiple processes on a comma and join with the regex operator '|' to perform "or" match against multiple applications
	$processName = $processName -split(",") -join ("|")	
	# Prompt the user as long as one of the matching processes are found running and store the processes description
	While (Get-Process | Where { $_.ProcessName -match $processName } -ErrorAction SilentlyContinue | Select Description -ExpandProperty Description | Select -Unique -OutVariable runningProcess) {	 
		$promptText = "The following application(s) must be closed before the script can proceed:`n`n" + ($runningProcess -join ",") + "`n`nPlease close the application and press OK to continue with the script."
		New-Prompt -PromptText $promptText -PromptWaitTime 0 -PromptTitle "Message from PowerShell Script" -PromptType 48		
		# Maintain one array of all the unique processes matched from every iteration of the while loop
		[array]$matchedProcess = (([array]$matchedProcess + [array]$runningProcess) | Select -Unique)
		# Make the matched processes available outside the scope of the function, so that the user can be notified later that applications can be used again
		If ($matchedProcess -ne $null) { 
			Set-Variable -Name matchedProcess -Value $MatchedProcess -Scope Script
		}		
	}
}

Stop-RunningApplications -ProcessName "iexplore,word,excel,powerpnt" 

#
# Do processing here ...
# 

# Notify user that the application(s) can be used again
If ($matchedProcess -ne $null) {
	$balloonText = "You may use " + ($matchedProcess -join ",") + " again."	
	Show-BalloonTip -BalloonTipIcon "Info" -BalloonTipText $balloonText -BalloonTipTitle "Windows PowerShell" -BalloonTipTime 1000	
}

PowerShell Script to edit QualysGuard remediation tickets

I spend a considerable amount of time in the QualysGuard web interface editing remediation tickets. It’s one of those admin tasks that you dread doing, but have to do.

If you’ve ever used the QualysGuard web user interface, you’ll know that it’s not particularly fast. Aside from the speed of the UI, there are also functional limitations. For example, you can only display 500 tickets per page. So, to edit 5,000 tickets, you need to browse through 10 pages and select all tickets on each page. Also, if you want to select tickets by ticket number or QID, you can only select one ticket number or QID at a time.

Thankfully, Qualys have provided a powerful API that overcomes these limitations. I have written a PowerShell script that takes full advantage of the ticket_edit.php API function to edit up to 20,000 remediation tickets at a time.

A detailed help section is provided in the script. In a PowerShell console, type: get-help .\edit-qualysticket.ps1 –detailed

You will need to configure the $urlQualysLocation variable to set the QualysGuard URL for your location. For more information on the Qualys API, visit here: https://community.qualys.com/community/developer

<#
.SYNOPSIS
This Powershell script uses the QualysGuard API to edit remedation tickets.

.DESCRIPTION
The script uses the ticket_edit.php API function in QualysGuard to edit remediation tickets in a QualysGuard subscription. This function allows you to change the ticket assignee, open and close tickets, and add comments to tickets. Several input parameters are available for ticket selection. At least one ticket selection parameter is required, and one edit parameter is required.

The API is more powerful than the QualysGuard User Interface in how you can select tickets for editing, e.g. ability to select multiple ticket numbers or QIDs at a time, ability to select up to 20,000 tickets at a time without having to browse and select 500 tickets per page using the web UI. 

.PARAMETER TicketNumbers
Tickets with certain ticket numbers. Specify one or more ticket numbers and/or ranges. Use a comma (,) to separate multiple tickets

.PARAMETER SinceTicketNumber
Tickets since a certain ticket number. Specify the lowest ticket number to be selected. Selected tickets will have numbers greater than or equal to the ticket number specified.

.PARAMETER UntilTicketNumber
Tickets until a certain ticket number. Specify the highest ticket number to be selected. Selected tickets will have numbers less than or equal to the ticket number specified.

.PARAMETER Overdue
Tickets that are overdue or not overdue. When not specified, overdue and non-overdue tickets are selected. Specify 1 to select only overdue tickets. Specify 0 to select only tickets that are not overdue.

.PARAMETER Invalid
Tickets that are invalid or valid. When not specified, both valid and invalid tickets are selected. Specify 1 to select only invalid tickets. Specify 0 to select only valid tickets. You can select invalid tickets owned by other users, not yourself.

.PARAMETER ModifiedSinceDateTime
Tickets modified since a certain date/time. Specify a date (required) and time (optional) since tickets were modified. Tickets modified on or after the date/time are selected. The start date/time is specified in YYYY-MMDD[THH:MM:SSZ] format (UTC/GMT), like “2006-01-01” or “2006-05-25T23:12:00Z”.

.PARAMETER UnModifiedSinceDateTime
Tickets not modified since a certain date/time. Specify a date (required) and time (optional) since tickets were not modified. Tickets not modified on or after the date/time are selected. The date/time is specified in YYYY-MM-DD[THH:MM:SSZ] format (UTC/GMT), like “2006-01-01” or “2006-05-25T23:12:00Z”.

.PARAMETER IPs
Tickets on hosts with certain IP addresses. Specify one or more IP addresses and/or ranges. Multiple entries are comma separated.

.PARAMETER DNSContains
Tickets on hosts that have a NetBIOS host name which contains a certain text string. Specify a text string to be used. This string may include a maximum of 100 characters.

.PARAMETER NETBIOSContains
Tickets on hosts that have a NetBIOS host name which contains a certain text string. Specify a text string to be used. This string may include a maximum of 100 characters.

.PARAMETER VendorRefContains
Tickets for vulnerabilities that have a vendor reference which contains a certain text string. Specify a text string. This string may include a maximum of 100 characters.

.PARAMETER TicketAssignee
Tickets with a certain assignee. Specify the user login of an active user account.

.PARAMETER States
Tickets with certain ticket state/status. Specify one or more state/status codes from (OPEN,RESOLVED,CLOSED,IGNORED). Use a comma (,) to separate multiple. A valid value is OPEN (for state/status Open or Open/Reopened), RESOLVED (for state Resolved), CLOSED(for state/status Closed/Fixed), or IGNORED (for state/status Closed/Ignored). 

.PARAMETER AssetGroups
Tickets on hosts with IP addresses which are defined in certain asset groups. Specify the title of one or more asset groups. Multiple asset groups are comma separated. The title “All” may be specified to select all IP addresses in the user account.

.PARAMETER VulnSevereties
Tickets for vulnerabilities with certain severity levels. Specify one or more severity levels. Multiple levels are comma separated. (1,2,3,4,5)

.PARAMETER PotVulnSevereties
Tickets for potential vulnerabilities with certain severity levels. Specify one or more severity levels. Multiple levels are comma separated. (1,2,3,4,5)

.PARAMETER QIDs
Tickets for vulnerabilities with certain QIDs (Qualys IDs). Specify one or more QIDs. A maximum of 10 QIDs may be specified. Multiple QIDs are comma separated.

.PARAMETER VulnTitleContains
Tickets for vulnerabilities that have a title which contains a certain text string. The vulnerability title is defined in the KnowledgeBase. Specify a text string and enclose in double quotes ("insert text here"). This string may include a maximum of 100 characters.

.PARAMETER VulnDetailsContains
Tickets for vulnerabilities that have vulnerability details which contain a certain text string. Vulnerability details provide descriptions for threat, impact, solution and results (scan test results, when available). Specify a text string and enclose in double quotes ("insert text here"). This string may include a maximum of 100 characters.

.PARAMETER ChangeAssignee
Used to change the ticket assignee, specified by user login, in all selected tickets. The assignee’s account must have a user role other than Contact, and the hosts associated with the selected tickets must be in the user account.

.PARAMETER ChangeState
Used to change the ticket state/status to the specified state/status in all selected tickets.(OPEN,RESOLVED,IGNORED) A valid value is OPEN (for state/status Open and Open/Reopened),RESOLVED (for state Resolved), or IGNORED (for state/status Closed/Ignored). 

.PARAMETER AddComment
Used to add a comment in all selected tickets. The comment text may include a maximum of 2,000 characters. Enclose all comments in double quotes ("insert text here").

.EXAMPLE
Edit-QualysTickets.ps1 -QIDs 76543 -ChangeState RESOLVED -AddComment "This vulnerability has been addressed. Changing ticket state to RESOLVED." 

.EXAMPLE
Edit-QualysTickets.ps1 -TicketNumbers 12345,23456 -Assignee usrts-ab -States OPEN,RESOLVED -ChangeAssignee usrts-bc -ChangeState IGNORED -AddComment "Re-assigning tickets from user usrts-ab to usrts-bc and changing state to IGNORED." 

.EXAMPLE
Edit-QualysTickets.ps1 -IPs 192.168.1,192.168.2 -ModifiedSinceDateTime 2011-09-16T00:00:00Z -States RESOLVED -VulnSevereties 4,5 -VulnTitleContains "Adobe Reader" -ChangeState OPEN

.NOTES
Requires a QualysGuard account with permission to edit tickets for a given asset group. Managers and Unit Managers have permission to run this API function.

A maximum of 20,000 tickets can be edited in one request. The QualysGuard credentials are transmitted using the "Basic Authentication Scheme" over HTTPS.

The Script saves the Qualys response in XML format and writes output to a log file. These files are saved in the %temp% directory. The $urlQualysLocation variable must be configured with the QualysGuard URL for your subscription location.

.LINK
https://powersheller.wordpress.com

#>

Param ([ValidateCount(1,1000)]
[string[]]$TicketNumbers,
[string]$SinceTicketNumber,
[string]$UntilTicketNumber,
[string]$TicketAssignee,
[ValidateSet("0","1")]
[string]$Overdue,
[ValidateSet("0","1")]
[string]$Invalid,
[ValidateSet("OPEN","RESOLVED","CLOSED","IGNORED")] 
[string[]]$States,
[string]$ModifiedSinceDateTime,
[string]$UnModifiedSinceDateTime,
[string[]]$IPs,
[string[]]$AssetGroups,
[ValidateLength(1,100)]
[string]$DNSContains,
[ValidateLength(1,100)]
[string]$NetbiosContains,
[ValidateSet("1","2","3","4","5")]
[string[]]$VulnSevereties,
[ValidateSet("1","2","3","4","5")]
[string[]]$PotVulnSevereties,
[ValidateCount(1,10)]
[string[]]$QIDs,
[ValidateLength(1,100)]
[string]$VulnTitleContains,
[ValidateLength(1,100)]
[string]$VulnDetailsContains,
[ValidateLength(1,100)]
[string]$VendorRefContains,
[string]$ChangeAssignee,
[ValidateSet("OPEN","RESOLVED","IGNORED")] 
[string]$ChangeState,
[ValidateLength(1,2000)]
[string]$AddComment
)

#*=============================================
#* VARIABLE DECLARATION
#*=============================================

# Variables: Qualys URLs
$urlQualysLocation = "qualysapi.qualys.eu"
#$urlQualysLocation = "qualysapi.qualys.com"
$urlQualys = "https://$urlQualysLocation/msp"
$urlQualysTicketEdit = "$urlQualys/ticket_edit.php?"
$xmlTicketEdit = Join-path $env:temp "Edit-QualysTicket.xml"

# Set the parameters to be passed to the Ticket_edit.php function if they are specified as script parameters.
### Ticket Selection parameters ###
If ($ticketNumbers -ne $null) { $paramTicketNumbers = "&ticket_numbers="+($ticketNumbers -join ",") }
If ($sinceTicketNumber -ne "") { $paramSinceTicketNumber = "&since_ticket_number="+$sinceTicketNumber }
If ($untilticketNumber -ne "") { $paramUntilticketNumber = "&since_ticket_number="+$untilticketNumber }
If ($overdue -ne "") { $paramOverdue = "&overdue="+$overdue }
If ($invalid -ne "") { $paramInvalid = "&overdue="+$invalid }
If ($ticketAssignee -ne "") { $paramAssignee = "&ticket_assignee="+$ticketAssignee }
If ($states -ne $null) { $paramStates = "&states="+($states -join ",") }
If ($modifiedSinceDateTime -ne "") { $parammodifiedSinceDateTime = "&modified_since_datetime="+$modifiedSinceDateTime }
If ($unmodifiedSinceDateTime -ne "") { $paramUnmodifiedSinceDateTime = "&unmodified_since_datetime="+$unmodifiedSinceDateTime }
If ($ips -ne $null) { $paramIps = "&ips="+($ips -join ",") }
If ($assetGroups -ne $null) { $paramAssetGroups = "&asset_groups="+($assetGroups -join ",") }
If ($dnsContains -ne "") { $paramDnsContains = "&dns_contains="+$dnsContains }
If ($netbiosContains -ne "") { $paramNetbiosContains = "&netbios_contains="+$netbiosContains }
If ($vulnSevereties -ne $null) { $paramVulnSevereties = "&vuln_severities="+($vulnSevereties -join ",") }
If ($potVulnSevereties -ne $null) { $paramPotVulnSevereties = "&potential_vuln_severities="+($potVulnSevereties -join ",") }
If ($qids -ne $null) { $paramQids = "&qids="+($qids -join ",") }
If ($vulnTitleContains -ne "") { $paramVulnTitleContains = "&vuln_title_contains="+$vulnTitleContains }
If ($vulnDetailsContains -ne "") { $paramVulnDetailsContains = "&vuln_details_contains="+$vulnDetailsContains }
If ($vendorRefContains -ne "") { $paramVendorRefContains = "&vendor_ref_contains="+$vendorRefContains }
### Ticket Action parameters ###
If ($changeAssignee -ne "") { $paramChangeAssignee = "&change_assignee="+$changeAssignee }
If ($changeState -ne "") { $paramChangeState = "&change_state="+$changeState }
If ($addComment -ne "") { $paramAddComment = "&add_comment="+$addComment }

$urlQualysTicketEditFinal = $urlQualysTicketEdit+$paramTicketNumbers+$paramSinceTicketNumber+$paramUntilticketNumber+$paramOverdue+$paramInvalid+$parammodifiedSinceDateTime+$paramUnmodifiedSinceDateTime+$paramIps+$paramTicketAssignee+$paramStates+$paramAssetGroups+$paramDnsContains+$paramNetbiosContains+$paramVulnSevereties+$paramPotVulnSevereties+$paramQids+$paramVulnTitleContains+$paramVulnDetailsContains+$paramVendorRefContains+$paramChangeAssignee+$paramChangeState+$paramAddComment

#*=============================================
#* END VARIABLE DECLARATION
#*=============================================

#*=============================================
#* FUNCTION LISTINGS
#*=============================================

Function Exit-Script {
Write-Log -Message "Exiting Script."
Exit
}

# Function to write output to the console and to log file
Function Write-Log {
	PARAM(
	[String]$Message,
	[String]$Path = "$env:temp\$(Split-Path $MyInvocation.ScriptName -Leaf).log"
	)
	
	Write-Host $message 
	$message | Out-File -FilePath $Path -Append -NoClobber
	} #end function

# Function to authenticate to Qualys, POST a URL request and retreive the response.
Function Connect-QualysAPI {
	# Parameters required: Qualys URL string and output XML file
	param($url,$xml)
	Write-Log -Message "URL request: $url"
	Write-Log -Message "Sending Request to Qualys API.."
	# Call Web Service and ignore erros with invalid, untrusted or expired SSL CertIficates (we know we are dealing with Qualys) - this only affects the current PowerShell runspace
	[System.Net.ServicePointManager]::ServerCertIficateValidationCallback = {$true}
	# Set up the webclient
	$webClient = new-object System.Net.WebClient
	$webClient.Credentials = new-object System.Net.NetworkCredential($username,$password)

	# Trap exceptions when calling DownloadString and call the Exit-Script function
	trap [System.Net.WebException] { 
		$exceptionMessage = $_.Exception.Message
		$exceptionName = $_.Exception.GetType().FullName
		Write-Log -Message "$exceptionName: $exceptionMessage"
		Exit-Script 
		}
	
	# Send request URL to Qualys, then wait until the request has been processed and downloaded fully.
	$xml = $webClient.DownloadString($url) | Out-File -FilePath $xml
	
} # End Function Connect-QualysAPI

# Function to parse the XML response response from Edit-QualysTicket
Function Get-QualysResponse {
	Param ($xmlResponse)
	Write-Log -Message "Checking response from Qualys..."
	# Read the Qualys XML response
	[xml]$xml = (Get-Content $xmlResponse)
		# If the request to edit the ticket resulted in a change, parse the results.
		If ($xml.Ticket_Edit_Output) {
			If ($xml.Ticket_Edit_Output[1].Changes)	{
				Write-Log -Message "Parsing response from Qualys..."
				$ticketNetbios = $xml.Ticket_Edit_Output[1].Header.Where.Netbios_Contains.InnerText
				$ticketChangesCount = $xml.Ticket_Edit_Output[1].Changes.getAttribute("count")
				$ticketChanges = $xml.Ticket_Edit_Output[1].Changes.TICKET_NUMBER_LIST
				$ticketSkippedCount = $xml.Ticket_Edit_Output[1].Skipped.getAttribute("count")
				$ticketSkipped = $xml.Ticket_Edit_Output[1].Skipped.TICKET_LIST.TICKET
					
				# If there was a change to one or more tickets, get the details
				If ($ticketChangesCount -gt 0) {
					$ticketChangesArray = @()
					Foreach ($ticketNumber in $ticketChanges.TICKET_NUMBER.InnerText) {			
						$ticketChangesNumber = $ticketChanges.TICKET_NUMBER
						$ticketChangesArray += $ticketChangesNumber
					}
					
					# Format the ticket numbers changed in to comma separated string (for printing on one line of log file).	
					$ticketChangesString = [string]::join(",", $ticketChangesArray)
					Write-Log -Message "$ticketChangesCount tickets edited."
					Write-Log -Message "Ticket Numbers changed: $ticketChangesString"										
				}
					
				# If editing one or more tickets was skipped, get the details and reason why.
				ElseIf ($ticketSkippedCount -gt 0) {
					$ticketSkippedArray = @()
					Write-Log -Message "$ticketNetbios - `nNumber of Tickets Skipped:$ticketCountSkipped `nTicket Numbers skipped:"
						Foreach ($ticketNumber in $ticketSkipped) {
							$ticketSkippedNumber = $ticketSkipped.Number.InnerText
							$ticketSkippedReason = $ticketSkipped.Reason.InnerText
							$ticketSkippedArray += "$ticketSkippedNumber:$ticketSkippedReason"
						}
					# Format the ticket numbers skipped along with the reason for skipping as a string.		
					$ticketSkippedString = [string]::join(",", $ticketSkippedArray)
					Write-Log -Message "The following tickets were skipped: $ticketSkippedString"
				}		
					
				# If no tickets were changed or skipped, something was wrong with the request, e.g. ticket state not Uppercase
				Else {
					Write-Log -Message "$ticketNetbios - No tickets were altered. There may not be any open tickets to edit."
				}
			}
			# If Qualys Returns an error, parse the error output
			ElseIf ($xml.Ticket_Edit_Output[1].Error) {
				Write-Log -Message "Parsing the Qualys XML error response..."
				$errorNumber = $xml.Ticket_Edit_Output[1].Error.getAttribute("number")
				$errorResponse = $xml.Ticket_Edit_Output[1].Error.InnerXml
				Write-Log -Message "Error: $errorNumber:$errorResponse"
				Exit-Script
			}	
		}
		
		# If Qualys returned a Generic Return form, call the Get-QualysGenericReturn function
		ElseIf ($xml.GENERIC_RETURN) {	
			Write-Log -Message "Parsing the Qualys XML generic response..."
			$genericApiName = $xml.GENERIC_RETURN[1].API.getAttribute("name")
			$genericapiUserName = $xml.GENERIC_RETURN[1].API.getAttribute("username")
			$genericApiTime = $xml.GENERIC_RETURN[1].API.getAttribute("at")
			$genericReturnStatus = $xml.GENERIC_RETURN[1].Return.getAttribute("status")
			$genericReturnNumber = $xml.GENERIC_RETURN[1].Return.getAttribute("number")
			$genericReturnDetails = $xml.GENERIC_RETURN[1].Return.InnerXml
			Write-Log -Message "API failure: $genericApiName `nUser: $genericapiUserName `nTime: $genericApiTime"
			Write-Log -Message "Status: $genericReturnStatus `nError Number: $genericReturnNumber `nDetails: $genericReturnDetails"

			# Call the script exit function If the return status is "failed"
			If ($genericReturnStatus -imatch "FAILED") {	
				Exit-Script
			} 
		}
		
		Else {
			Write-Log -Message "Error: Qualys request was unsuccessful. Check parameters."
			Exit-Script
		}	

} # End Function Get-QualysResponse

Function Invoke-QualysTicketEdit {

Try {
	$credential = Get-Credential
}
Catch {
	Write-Log -Message "Error: Credentials are required to authenticate to Qualys"
	Exit-Script
}
$networkCredential = $credential.GetNetworkCredential()
$username = $networkCredential.UserName
$password = $networkCredential.Password

Connect-QualysAPI $urlQualysTicketEditFinal $xmlTicketEdit

Get-QualysResponse $xmlTicketEdit

Exit-Script
}

#*=============================================
#* END FUNCTION LISTINGS
#*=============================================


#*=============================================
#* SCRIPT BODY
#*=============================================

Invoke-QualysTicketEdit

#*=============================================
#* END SCRIPT BODY
#*=============================================

SCCM 2012 – Odd problem in MDT Task Sequence

I’m using Configuration Manager 2012 Beta 2 with MDT 2012 Beta 1 to create task sequences for OSD. This error bugged me for days before I found a fix. The error code 0x80004005 is apparently an access denied error. Most of the forums on this error point to certificate issues or permissions issues. Having checked the certificates and permissions, re-created the task sequence, re-created packages and re-distributed everything I was still getting the same error.

image

The SMSTS.LOG file looks like this:

<![LOG[Executing Policy Body Request.]LOG]!><time="09:13:02.784-60" date="07-07-2011" component="TSMBootstrap" context="" type="0" thread="1064" file="tspolicy.cpp:2010">
<![LOG[Using Authenticator for policy]LOG]!><time="09:13:02.784-60" date="07-07-2011" component="TSMBootstrap" context="" type="1" thread="1064" file="libsmsmessaging.cpp:4448">
<![LOG[CLibSMSMessageWinHttpTransport::Send: URL: SCCMServer:80  GET /SMS_MP/.sms_pol?ScopeId_647BE467-0336-4ECD-9411-6BEDD13345A/AuthList_4b3c67e6-43bb-4f8d-8362-3504308e3452/VI.SHA256:GHFSD3456DHDSGH91FE949D8A0445EFA08701D81C984564]LOG]!><time="09:13:02.784-60" date="07-07-2011" component="TSMBootstrap" context="" type="1" thread="1064" file="libsmsmessaging.cpp:8720">
<![LOG[Request was succesful – 200]LOG]!><time="09:13:02.877-60" date="07-07-2011" component="TSMBootstrap" context="" type="0" thread="1064" file="libsmsmessaging.cpp:9046">
<![LOG[cannot load compressed XML policy]LOG]!><time="09:13:04.018-60" date="07-07-2011" component="TSMBootstrap" context="" type="3" thread="1064" file="libsmsmessaging.cpp:4617">
<![LOG[oPolicy.RequestPolicy((GetPolicyFlags() & POLICY_SECURE) != 0, (GetPolicyFlags() & POLICY_COMPRESS) != 0), HRESULT=80004005 (e:\nts_sccm_retail\sms\framework\tscore\tspolicy.cpp,2014)]LOG]!><time="09:13:04.018-60" date="07-07-2011" component="TSMBootstrap" context="" type="0" thread="1064" file="tspolicy.cpp:2014">
<![LOG[Failed to download policy ScopeId_647BE467-0336-4ECD-9411-6BEDD13345A/AuthList_4b3c67e6-43bb-4f8d-8362-3504308e3452/VI (Code 0x80004005).]LOG]!><time="09:13:04.018-60" date="07-07-2011" component="TSMBootstrap" context="" type="3" thread="1064" file="tspolicy.cpp:2014">
<![LOG[DownloadPolicyBody(), HRESULT=80004005 (e:\nts_sccm_retail\sms\framework\tscore\tspolicy.cpp,2100)]LOG]!><time="09:13:04.018-60" date="07-07-2011" component="TSMBootstrap" context="" type="0" thread="1064" file="tspolicy.cpp:2100">

I used the free text search feature in SCCM to search all objects for the string after AuthList_ 4b3c67e6-43bb-4f8d-8362-3504308e3452

The search returned 2 related objects:

A configuration Item in Assets and Compliance.

A software update group in the Software Library.

Checking the properties of these, it appeared that there was no security scope set on the configuration item. When I tried to set the security scope, Config Mgr crashed. I looked in Assets and Compliance and the item wasn’t there. So I ran the search again and deleted the item from the search results. The item was applied to a collection that included the machine running the TS. When I re-ran the task sequence, the error was gone! It goes to show how something seemingly unrelated to the task sequence can cause problems with it.

Some more SCCM 2012 gotcha’s here:

http://scug.be/blogs/nico/archive/2011/04/26/sccm-2012-odd-issues.aspx

PowerShell: Re-creating the Local Group Policy Database File

Introduction

Last week I came across a client-side issue applying Group Policies. It turned out to be a corrupt local group policy database file. Here’s how I went about troubleshooting and fixing the issue and then automating detection and resolution of the problem using PowerShell.

Troubleshooting

The Resultant Set of Policy (RSoP) indicated a problem processing the policy settings and advised to look in the Application event log for more details, and quite helpfully showed the exact time of the expected event to look for.

Looking at the Application event log, there was a warning event at the expected time with event ID 1202, with this description:

"Security policies were propagated with warning. 0x4b8 : An extended error has occurred."

The event description suggests searching http://support.microsoft.com which turned up this KB article:

http://support.microsoft.com/kb/324383 – Troubleshooting SCECLI 1202 Events

Scrolling down to Win32 error code for Event ID 1202 "0x4b8: An extended error has occurred", the article tell us how to enable detailed debugging to help troubleshoot the problem. It also points to this KB article:

http://support.microsoft.com/kb/278316/ – ESENT Event IDs 1000, 1202, 412, and 454 Are Logged Repeatedly in the Application Event Log

And there we see Message 2 – the exact error from our event log.

Resolution

The problem is that the local Group Policy database is corrupt. To fix it, we will need to re-create the local security database. This procedure is described in the article. The article doesn’t tell us what might have caused the problem in the first place. There could be any number of causes and finding one is going to take a bit more investigative work, but for now we’ll just worry about fixing the problem.

After re-creating the security database from template, per the instructions, I ran the gpupdate /force command. Hey presto! Group policy now applies correctly. This Application event log entry confirms it – Event ID 1704:

Automation

I discovered that this problem was more widespread than just one machine, so I set about looking to automate detection and resolution of the problem. Thankfully secedit.exe provides command line switches to do this:

http://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/secedit_cmds.mspx?mfr=true

I decided to use this in a PowerShell script. The first thing to do was to find a way to detect whether the problem exists. Using Get-EventLog, I queried for the exact 1202 event and checked if any 1704 events had occurred since the 1202 event. If a 1704 event has occurred since the 1202 warning, I ignore it, because Group Policy has applied successfully.

Next, the script takes a backup of the secedit.sdb for rollback and proceeds to re-create the local group policy database file using the secedit command line.

In my tests, secedit.exe returned an error code 3, although the task appeared to complete successfully and the issue was resolved. I checked the secedit log file and found this entry at the end of the file:

"Task is completed. Some files in the configuration are not found on this system so security cannot be set/queried. It’s ok to ignore."

I can only assume that this is the reason for error code 3 being returned. The script deems error level 3, combined with this entry in the log to be a successful re-creation of the database file, otherwise it rolls back to the original secedit.sdb file.

I have provided the code below, which needs to be run locally on the machine. The script has only been tested on Windows XP. You could deploy this out via Group Policy, but it won’t be much good if the computers you are trying to target aren’t applying Group Policies! So, you’ll have to use a software distribution tool, psexec, or some other mechanism.

Warning: Re-creating the local security database file can reset permissions on a whole bunch of files and registry keys, so do this at your own peril.

#*=============================================
#* FUNCTION LISTINGS
#*=============================================

Function Exit-Script {
	Param($exitCode)
	Write-Log "Exiting Script."
	Exit $exitCode
}

# Function to write output to the host and to log file
Function Write-Log {
	[CmdletBinding(DefaultParametersetName="LogEntry")] 
    Param(
	[Parameter(ParameterSetName="LogEntry", Position = 0, Mandatory = $true, ValueFromPipeline = $true)][ValidateNotNullOrEmpty()]
    [String]$Message,
    [Parameter(Position = 1)]
    [String]$LogFile = "$env:temp\$(Split-Path $MyInvocation.ScriptName -Leaf).log",
	[Parameter(ParameterSetName="LogEntry", Position = 2)] [ValidateSet("Error", "Warning")]
	[string]$Type = "",	
	[Parameter(ParameterSetName="NewLine", Position=0)] 
	[switch]$NewLine	
    )		
	
	switch ($PsCmdlet.ParameterSetName) 
    { 
	    "NewLine" 	{ Write-Host ""; "" | Out-File -FilePath $LogFile -Append }
	    "LogEntry" 	{			
			
			$date = Get-Date -Format "yyyy-MM-dd HH:mm:ss"	
			$psCallStack = Get-PSCallStack
			
			# Get the function name from where the Write-Log function was called
			If ($psCallStack[1].command -notmatch ".") { $function = $psCallStack[1].command } Else { $function = "Script" } 
			
			$logColumns = @("$date","$function","$($Type.ToUpper())","$message")
			[string]$logMessage = ""	
			
			# Build the message string to insert colon only where needed
			Foreach ($logColumn in $logColumns) { 
				If ($logColumn -ne "") {
					If ($logMessage -eq "" ) { 
						$logMessage = $logColumn
					}
					Else { 
						$logMessage += [string]::join("$logMessage"," : $logColumn")
					}
				}							
			}

			# Write message to the screen
			Switch ($type) {
				"Error" 	{ $logMessage += ": Line $($MyInvocation.ScriptLineNumber)"; Write-Host $logMessage -ForegroundColor Red }
				"Warning" 	{ Write-Host $logMessage -ForegroundColor Yellow }
				Default		{ Write-Host $logMessage }				
			}
			
			# Write message to the log file
		    Write-Output $logMessage | Out-File -FilePath $LogFile -Append
		}
	}
} #end function

# Function to start a CLI application and return the exit code
Function Start-CliApplication { 
    param ( [string]$application, [string]$arguments )
	
    # Build Startinfo and set options according to parameters
	$startInfo = new-object System.Diagnostics.ProcessStartInfo 
   	$startInfo.FileName = $application
	$startInfo.Arguments = $arguments
	$startInfo.WindowStyle = "Hidden"
	$startInfo.CreateNoWindow = $true
	$startInfo.UseShellExecute = $false
    	
	# Start the process
	$process = [System.Diagnostics.Process]::Start($startinfo)

	# Wait until the process finished
	Do {
 		If( -not $process.HasExited ) {
  			$process.Refresh()
		}
	} While( -not $process.WaitForExit(1000) )
	
	# Output the exitcode
	Write $process.exitcode
}

Function Backup-SecDB {
	If (Test-Path (Join-Path $env:SystemRoot "Security\Database\secedit.sdb")) {
		Write-Log "Backing up Security Database..."
		Rename-Item -Path (Join-Path $env:SystemRoot "Security\Database\secedit.sdb") -NewName "secedit.old" -Force
	}
}

Function Restore-SecDB {
	If (Test-Path (Join-Path $env:SystemRoot "Security\Database\secedit.old")) {
		Write-Log "Restoring Old Security Database..."
		Rename-Item -Path (Join-Path $env:SystemRoot "Security\Database\secedit.old") -NewName "secedit.sdb" -Force
	}
}

Function Reset-SecurityDatabase {
	# Reset the security database from template
	Write-Log "Regenerating Security Database..."
	$RegenerationResult = Start-CliApplication -Application "SECEDIT.EXE" -Arguments "/CONFIGURE /CFG `"$env:systemroot\Security\Templates\setup security.inf`" /DB `"$env:temp\secedit.sdb`" /OVERWRITE /LOG `"$env:temp\SeceditRegeneration.log`" /QUIET"
	If ($RegenerationResult -eq 0) {
        Write-Log "Regeneration of Security Database Successful" 
    }
    ElseIf ($RegenerationResult -eq 3) {
        If (Test-Path (Join-Path $env:temp "\SeceditRegeneration.log")) { 
            $seceditRegenerationLog = Get-Content (Join-Path $env:temp "\SeceditRegeneration.log")
            If ($seceditRegenerationLog -match "It's ok to ignore") {
                 Write-Log "Regeneration of Security Database Successful."
            }
            Else { Write-Log "Regeneration of Security Database Failed." -Type Error; Restore-SecDB; Exit-Script 1 } 
        }
        Else { Write-Log "Regeneration of Security Database Failed." -Type Error; Restore-SecDB; Exit-Script 1 } 
   	}
   	Else { Write-Log "Regeneration of Security Database Failed." -Type Error; Restore-SecDB; Exit-Script 1 } 

	Write-Log "Replacing Security Database..."
	Move-Item -Path (Join-Path $env:Temp "secedit.sdb") -Destination (Join-Path $env:SystemRoot Security\Database) -Force
}

Function Update-GroupPolicy {
	# Update Group Policy
	Write-Log "Updating Group Policy..."
	$gpupdateResult = Start-CliApplication "gpupdate" "/force"
	If ($gpUpdateResult -eq 0) {
		Write-Log "Group Policy Update Successful"
	}
	Else { Write-Log "Group Policy Update Failed" -Type Error; Exit-Script 1}
}

#*=============================================
#* END FUNCTION LISTINGS
#*=============================================

#*=============================================
#* SCRIPT BODY
#*=============================================

Write-Log "Searching Event Log for Security Database errors..."
$eventSceCliErrors = Get-EventLog Application -Newest 500 | Where { $_.eventID -eq "1202" -and $_.source -eq "SceCli" -and $_.message -match "0x4b8" } | Sort-Object -Property TimeGenerated -Descending | Select-Object -First 1
$eventSceCliSuccess = Get-EventLog Application -Newest 500 | Where { $_.eventID -eq "1704" -and $_.source -eq "SceCli" -and $_.message -match "successfully" } | Sort-Object -Property TimeGenerated -Descending | Select-Object -First 1

# If group policy has not been applied successfully since the last SceCli extended error, initiate remediation action. 
If ($eventSceCliErrors.TimeGenerated -gt $eventSceCliSuccess.TimeGenerated) {
	Write-Log "Group Policy has not been applied successfully since the last SceCLI Errors detected in the Event Log. Initiating Remediation Action..." -Type Warning
	
	# Backup the security database file
	Backup-SecDB
	
	# Regenerate the local security database from template
	Reset-SecurityDatabase
	
	# Update Group Policy
	Update-GroupPolicy
}
ElseIf ($eventSceCliErrors) {
	Write-Log "SceCLI Errors detected in the Event Log but Group Policy has successfully applied since the errors were generated.`nNo action necessary."
}
Else {
	Write-Log "No SceCLI Errors detected in the Event Log."
}

Write-Log "Script Complete."
Exit-Script 0

#*=============================================
#* END SCRIPT BODY
#*=============================================


This code is provided "as is" and without warranties as to performance or merchantability. The author and/or distributors of this code may have made statements about this code. Any such statements do not constitute warranties and shall not be relied on by the user in deciding whether to use this code.

This code is provided without any express or implied warranties whatsoever. Because of the diversity of conditions and hardware under which this code may be used, no warranty of fitness for a particular purpose is offered. The user is advised to test the code thoroughly before relying on it. The user must assume the entire risk of using the code.