diff --git a/docs/docsite/rst/os_guide/intro_windows.rst b/docs/docsite/rst/os_guide/intro_windows.rst index fd626737b23..56b7bb979e4 100644 --- a/docs/docsite/rst/os_guide/intro_windows.rst +++ b/docs/docsite/rst/os_guide/intro_windows.rst @@ -13,6 +13,7 @@ This is an index of all the topics covered in this guide. .. toctree:: :maxdepth: 1 + windows_app_control windows_dsc windows_performance windows_ssh @@ -161,6 +162,8 @@ While not all connection plugins require the connection user to be a member of t Learning Ansible's configuration management language :ref:`developing_modules` How to write modules + :ref:`windows_app_control` + Using Ansible with Windows App Control managed hosts :ref:`windows_dsc` Using Ansible with Windows Desired State Configuration :ref:`windows_performance` diff --git a/docs/docsite/rst/os_guide/windows_app_control.rst b/docs/docsite/rst/os_guide/windows_app_control.rst new file mode 100644 index 00000000000..36e3e3ea3e8 --- /dev/null +++ b/docs/docsite/rst/os_guide/windows_app_control.rst @@ -0,0 +1,153 @@ +.. _windows_app_control: + +Windows App Control +=================== +`Windows App Control `_, formerly known as Windows Defender Application Control (``WDAC``), is a security feature of Windows that can be used to restrict what executables and scripts can be run on a Windows host. In the past, enabling WDAC will cause Ansible to fail when running on the Windows host. Starting with Ansible 2.19 and the ``ansible.windows`` collection at ``3.1.0``, Ansible can now run on Windows hosts with WDAC enabled. + +.. admonition:: Experimental functionality + + The App Control implementation is considered an experimental feature and can change in future releases. It is not possible to ensure all PowerShell modules will work with App Control enabled and that a module might enable arbitrary code to run in a way not typically allowed by App Control. It is recommended to test all modules with WDAC enabled before using them in production. + +.. contents:: + :local: + +Requirements for Ansible to work with App Control +------------------------------------------------- +Ansible requires the target Windows version to be Windows Server 2019 or Windows 10 Build 1803 or later. This is because the ``Dynamic Code Security`` feature added in that Windows version is required to allow Ansible to run tasks on the Windows host. + +The first step towards enabling App Control is to create a code signing certificate that will be used to sign the scripts used by Ansible. While this certificate can be self signed, it is recommended that it is issued by a trusted certificate authority used in your organization. How to generate this certificate is outside the scope of this documentation. Once the certificate is setup, the policy file must be generated and applied to the Windows host. + +Setting up App Control and configuring policies is not covered under the documentation here. Please read through the Microsoft documentation for `Application Control for Windows `_ or `Application Control with PowerShell `_ to understand how to configure App Control and set up policies. The `App Control for Business Wizard `_ is a tool that can simplify policy generation through a more user friendly GUI. + +When setting up a policy it is recommended to configure Ansible as a supplemental policy so it can be easily modified and applied where Ansible will be used. Whether you use a supplemental or just a base policy for trusting the certificate used by Ansible, the base policy must have the following options set: + +* User Mode Code Integrity (``0 Enabled:UMCI``) is enabled +* Disable Script Enforcement (``11 Disabled:Script Enforcement``) is not enabled +* Dynamic Code Security (``19 Enabled:Dynamic Code Security``) is enabled + +The policy then should then add the certificate as a trusted publisher to the ``User Mode Signing Scenario``, for example this is an example policy configuration that contains a trusted publisher: + +.. code-block:: text + + + ... + + + + + + + + + + + + + + + + + + + ... + + +Once the policy is created and the certificate that will be used to sign the Ansible content is trusted by the Windows host, the policy can be applied. + +.. Warning:: + As Ansible typically runs tasks as an Administrator, it is important that the policy is signed and is applied so that Ansible cannot unset the policy through a task like ``win_file`` or ``win_regedit``. + +How to Sign Ansible Content +--------------------------- +Once the code signing certificate has been generated and trusted by the Windows host, it can be used to sign the scripts that Ansible will run. The PowerShell script `New-AnsiblePowerShellSignature.ps1 `_ can be used to sign both the execution wrapper used by Ansible to invoke modules and any PowerShell modules inside an Ansible collection. It requires the following to run: + +* PowerShell 7.4 or later +* The `OpenAuthenticode `_ PowerShell module +* Python with Ansible and the required collections installed +* Access to the certificate and private key trusted by the App Control policy, typically as a PFX file + +.. note:: + The ``New-AnsiblePowerShellSignature`` function is not officially supported and is marked as a tech preview. + +To sign the Ansible PowerShell wrapper scripts, and modules in a collection, the following PowerShell script can be used with the loaded function from above: + +.. code-block:: powershell + + $certPassword = Read-Host "Enter the password for the certificate" -AsSecureString + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + "wdac-cert.pfx" + $certPassword) + + $signingParams = @{ + Certificate = $cert + + Collection = @( + # Includes all the builtin execution wrappers and scripts needed for Ansible + 'ansible.builtin' + + # Add any remaining collections used in the playbook like microsoft.ad, community.windows, etc. + 'ansible.windows' + 'microsoft.ad' + 'microsoft.iis' + 'community.windows' + ) + + # The URL of the Authenticode timestamp server to use for timestamping + # the signature. + # https://learn.microsoft.com/en-us/windows/win32/seccrypto/time-stamping-authenticode-signatures + TimeStampServer = '...' + } + New-AnsiblePowerShellSignature @signingParams -Verbose + +The ``ansible.builtin`` collection refers to the builtin execution scripts used in Ansible. Any other collection with PowerShell modules used in the playbook should be added to the ``-Collection`` parameter. The script will generate the ``powershell_signatures.psd1`` script signed by the certificate and contains the hashes of all the modules in the collection that should be trusted to run. It will also generate the signature for Ansible's execution wrapper script in the Ansible installation directory so that Ansible can automatically run the script trusted by the App Control policy. The current behavior of ``New-AnsiblePowerShellSignature`` is to sign all the modules in the collection and the Ansible execution wrapper script even if they could include an escape hatch. It is recommended to skip any modules using the ``-Skip`` parameter that are not needed in the playbook, for example: + +.. code-block:: powershell + + New-AnsiblePowerShellSignature ... -Skip @( + 'ansible.windows.win_dsc' + 'ansible.windows.win_timezone' + ) + +Any PowerShell content that is not part of a collection, like custom scripts or code used in ``ansible.windows.win_powershell``, must be signed manually using the ``Set-AuthenticodeSignature`` cmdlet on Windows or ``Set-OpenAuthenticodeSignature`` through ``OpenAuthenticode`` module on Linux. It is important that these signed scripts are used in a way that will not modify the contents of the script or else the signature will be invalidated. For example the ``ansible.builtin.script`` module will copy the script file to the target host as is leaving the signature intact but using the ``ansible.builtin.file`` lookup will strip any remaining newline characters unless the ``rstrip=False`` option is used. + +Known Module Differences +------------------------ +When App Control is enabled, some modules may not work, or behave differently, even if signed. Some of the known differences are: + +* ``ansible.windows.win_command`` can only execute executables trusted by the App Control policy. If the executable is not trusted, the module will fail +* ``ansible.windows.win_shell`` will run all code in Constrained Language Mode (``CLM``) which is highly restricted and may cause some scripts to fail +* ``ansible.windows.win_powershell`` will run in CLM by default unless the provided script is signed +* ``ansible.builtin.script`` will run in CLM by default unless the provided script is signed +* ``ansible.windows.win_package`` can only run executables trusted by the App Control policy so may or may not work depending on the executable +* ``ansible.windows.win_updates`` is currently not supported and will not work + +Other modules that start sub-processes or rely on unsigned PowerShell content will most likely not work with App Control enabled. + +If trying to run a PowerShell script with ``ansible.windows.win_powershell`` or ``ansible.builtin.script``, the script itself must be signed or else it will be run in CLM. + +.. code-block:: yaml + + - name: Test out LanguageMode + ansible.windows.win_powershell: + script: $ExecutionContext.SessionState.LanguageMode + +It is important that when referencing a signed script that the script is not modified in any way. This means the line endings and whitespace that were present when it was signed must be the same when Ansible uses the signed script. + +.. note:: + Ansible will always load the script with the UTF-8 encoding even if no Byte Order Mark (``BOM``) is present. It is important that the script was encoded with UTF-8 without a BOM when it was signed so that the signature stays valid. If the script was signed with a different encoding, the signature could be invalidated or PowerShell may interpret it with different characters. + +When referencing a signed script in Ansible, it is important that it is used in a way that does not modify the contents of the script which would break the signature. For example you should have the signed script in the local ``files`` directory associated with the playbook/tasks and reference in one of the following ways: + +.. code-block:: yaml + + - name: Run signed script through the script module + ansible.builtin.script: signed-script.ps1 + + - name: Run signed script through win_powershell as a path + ansible.windows.win_powershell: + path: signed-script.ps1 + + - name: Run signed script through win_powershell as inline content + ansible.windows.win_powershell: + # rstrip=False is important so the last \r\n of the signature is not removed. + script: "{{ lookup('ansible.builtin.file', 'signed-script.ps1', rstrip=False) }}" diff --git a/examples/scripts/New-AnsiblePowerShellSignature.ps1 b/examples/scripts/New-AnsiblePowerShellSignature.ps1 new file mode 100644 index 00000000000..b6d68c76aa1 --- /dev/null +++ b/examples/scripts/New-AnsiblePowerShellSignature.ps1 @@ -0,0 +1,407 @@ +# 0.5.0 fixed BOM-less encoding issues with Unicode +#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' } +#Requires -Version 7.4 + +using namespace System.Collections.Generic +using namespace System.IO +using namespace System.Management.Automation +using namespace System.Management.Automation.Language +using namespace System.Security.Cryptography.X509Certificates + +Function New-AnsiblePowerShellSignature { + <# + .SYNOPSIS + Creates and signed Ansible content for App Control/WDAC. + + .DESCRIPTION + This function will generate the powershell_signatures.psd1 manifest and sign + it. The manifest file includes all PowerShell/C# module_utils and + PowerShell modules in the collection(s) specified. It will also create the + '*.authenticode' signature file for the exec_wrapper.ps1 used inside + Ansible itself. + + This script should not be used directly from this URL. Download a copy of + the script and store it in a location that you control. It is possible a + future version of this script at this URL will include a breaking change. + + .PARAMETER Certificate + The certificate to use for signing the content. + + .PARAMETER Collection + The collection(s) to sign. This is set to ansible.builtin by default but + can be overridden to include other collections like ansible.windows. + + .PARAMETER Skip + A list of plugins to skip by the fully qualified name. Plugins skipped will + not be included in the signed manifest. This means that modules will be run + in CLM mode and module_utils will be skipped entirely. + + The values in the list should be the fully qualified name of the plugin as + referenced in Ansible. The value can also optionally include the extension + of the file if the FQN is ambiguous, e.g. collection util that has both a + PowerShell and C# util of the same name. + + Here are some examples for the various content types: + + # Ansible Builtin Modules + 'ansible.builtin.module_name' + + # Ansible Builtin ModuleUtil + 'Ansible.ModuleUtils.PowerShellUtil' + 'Ansible.CSharpUtil' + + # Collection Modules + 'namespace.name.module_name' + + # Collection ModuleUtils + 'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil' + 'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil.psm1' + + 'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil' + 'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil.cs' + + .PARAMETER Unsupported + A list of plugins to be marked as unsupported in the manifest and will + error when being run. Like -Skip, the values here are the fully qualified + name of the plugin as referenced in Ansible. + + .PARAMETER TimeStampServer + Optional authenticode timestamp server to use when signing the content. + + .EXAMPLE + Signs just the content included in Ansible. + + $cert = [X509Certificate2]::new("wdac-cert.pfx", "password") + New-AnsiblePowerShellSignature -Certificate $cert + + .EXAMPLE + Signs just the content include in Ansible and the ansible.windows collection + + $cert = [X509Certificate2]::new("wdac-cert.pfx", "password") + New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.builtin, ansible.windows + + .EXAMPLE + Signs just the content in the ansible.windows collection + + $cert = [X509Certificate2]::new("wdac-cert.pfx", "password") + New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.windows + + .EXAMPLE + Signs content but skips the specified modules and module_utils + $skip = @( + # Skips the module specified + 'namespace.name.module' + + # Skips the module_utils specified + 'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil' + 'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil' + + # Skips signing the file specified + 'ansible_collections.namespace.name.plugins.plugin_utils.powershell.file.ps1' + ) + $cert = [X509Certificate2]::new("wdac-cert.pfx", "password") + New-AnsiblePowerShellSignature -Certificate $cert -Collection namespace.name -Skip $skip + + .NOTES + This function requires Ansible to be installed and available in the PATH so + it can find the Ansible installation and collection paths. + #> + [CmdletBinding()] + param ( + [Parameter( + Mandatory + )] + [X509Certificate2] + $Certificate, + + [Parameter( + ValueFromPipeline, + ValueFromPipelineByPropertyName + )] + [string[]] + $Collection = "ansible.builtin", + + [Parameter( + ValueFromPipelineByPropertyName + )] + [string[]] + $Skip = @(), + + [Parameter( + ValueFromPipelineByPropertyName + )] + [string[]] + $Unsupported = @(), + + [Parameter()] + [string] + $TimeStampServer + ) + + begin { + $backupEnv = @{ + ANSIBLE_VERBOSITY = $env:ANSIBLE_VERBOSITY + ANSIBLE_DEVEL_WARNING = $env:ANSIBLE_DEVEL_WARNING + ANSIBLE_INVENTORY_UNPARSED_WARNING = $env:ANSIBLE_INVENTORY_UNPARSED_WARNING + ANSIBLE_NOCOLOR = $env:ANSIBLE_NOCOLOR + } + + $Unsupported = @( + $Unsupported + + # Known to not work, requires more changes to both Ansible and + # win_updates to support so we hardcode this as unsupported. + 'ansible.windows.win_updates' + ) + + Write-Verbose "Attempting to get ansible-config dump" + $env:ANSIBLE_VERBOSITY = "0" + $env:ANSIBLE_DEVEL_WARNING = "false" + $env:ANSIBLE_INVENTORY_UNPARSED_WARNING = "false" + $env:ANSIBLE_NOCOLOR = "true" + $configRaw = ansible-config dump --format json --type base 2>&1 + if ($LASTEXITCODE) { + $err = [ErrorRecord]::new( + [Exception]::new("Failed to get Ansible configuration, RC: ${LASTEXITCODE} - $configRaw"), + 'FailedToGetAnsibleConfiguration', + [ErrorCategory]::NotSpecified, + $null) + $PSCmdlet.ThrowTerminatingError($err) + } + + $config = $configRaw | ConvertFrom-Json + $collectionsPaths = @($config | Where-Object name -EQ 'COLLECTIONS_PATHS' | ForEach-Object value) + Write-Verbose "Collections paths to be searched: [$($collectionsPaths -join ":")]" + + $signParams = @{ + Certificate = $Certificate + HashAlgorithm = 'SHA256' + } + if ($TimeStampServer) { + $signParams.TimeStampServer = $TimeStampServer + } + + $checked = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + Function New-HashEntry { + [OutputType([PSObject])] + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [FileInfo] + $File, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] + $PluginBase, + + [Parameter()] + [AllowEmptyCollection()] + [string[]] + $Unsupported = @(), + + [Parameter()] + [AllowEmptyCollection()] + [string[]] + $Skip = @() + ) + + process { + $nameWithoutExt = [string]::IsNullOrEmpty($PluginBase) ? $File.BaseName : "$PluginBase.$($File.BaseName)" + $nameWithExt = "$nameWithoutExt$($File.Extension)" + + $mode = 'Trusted' + if ($nameWithoutExt -in $Skip -or $nameWithExt -in $Skip) { + Write-Verbose "Skipping plugin '$nameWithExt' as it is in the supplied skip list" + return + } + elseif ($nameWithoutExt -in $Unsupported -or $nameWithExt -in $Unsupported) { + Write-Verbose "Marking plugin '$nameWithExt' as unsupported as it is in the unsupported list" + $mode = 'Unsupported' + } + + Write-Verbose "Hashing plugin '$nameWithExt'" + $hash = Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256 + [PSCustomObject]@{ + Name = $nameWithExt + Hash = $hash.Hash + Mode = $mode + } + } + } + } + + process { + $newHashParams = @{ + Skip = $Skip + Unsupported = $Unsupported + } + + foreach ($c in $Collection) { + try { + if (-not $checked.Add($c)) { + Write-Verbose "Skipping already processed collection $c" + continue + } + + $metaPath = $null + $pathsToSign = [List[FileInfo]]::new() + $hashedPaths = [List[PSObject]]::new() + + if ($c -eq 'ansible.builtin') { + Write-Verbose "Attempting to get Ansible python path" + $ansiblePython = ansible localhost -m debug -a 'var=ansible_playbook_python' + $resultStart = ($ansiblePython -join "").IndexOf('{') + if ($LASTEXITCODE -or $resultStart -eq -1) { + throw "Failed to find Ansible Python Interpreter path, RC: ${LASTEXITCODE} - $ansiblePython" + } + $python = (($ansiblePython -join "").Substring($resultStart) | ConvertFrom-Json).ansible_playbook_python + + Write-Verbose "Attempting to get Ansible installation path" + $ansiblePath = & $python -c "import ansible; print(ansible.__file__)" 2>&1 + if ($LASTEXITCODE) { + throw "Failed to find Ansible installation path, RC: ${LASTEXITCODE} - $ansiblePath" + } + + $ansibleBase = Split-Path -Path $ansiblePath -Parent + $metaPath = [Path]::Combine($ansibleBase, 'config') + + $execWrapper = Get-Item -LiteralPath ([Path]::Combine($ansibleBase, 'executor', 'powershell', 'exec_wrapper.ps1')) + $pathsToSign.Add($execWrapper) + + $ansiblePwshContent = [PSObject[]]@( + # These are needed for Ansible and cannot be skipped + Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'executor', 'powershell', '*.ps1')) -Exclude "bootstrap_wrapper.ps1" | + New-HashEntry -PluginBase "ansible.executor.powershell" + + # Builtin utils are special where the filename is their FQN + Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'csharp', '*.cs')) | + New-HashEntry -PluginBase "" @newHashParams + Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'powershell', '*.psm1')) | + New-HashEntry -PluginBase "" @newHashParams + + Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'modules', '*.ps1')) | + New-HashEntry -PluginBase $c @newHashParams + ) + $hashedPaths.AddRange($ansiblePwshContent) + } + else { + Write-Verbose "Attempting to get collection path for $c" + $namespace, $name, $remaining = $c.ToLowerInvariant() -split '\.' + if (-not $name -or $remaining) { + throw "Invalid collection name '$c', must be in the format 'namespace.name'" + } + + $foundPath = $null + foreach ($path in $collectionsPaths) { + $collectionPath = [Path]::Combine($path, 'ansible_collections', $namespace, $name) + + Write-Verbose "Checking if collection $c exists in '$collectionPath'" + if (Test-Path -LiteralPath $collectionPath) { + $foundPath = $collectionPath + break + } + } + + if (-not $foundPath) { + throw "Failed to find collection path for $c" + } + + Write-Verbose "Using collection path '$foundPath' for $c" + + $metaPath = [Path]::Combine($foundPath, 'meta') + + $collectionPwshContent = [PSObject[]]@( + $utilPath = [Path]::Combine($foundPath, 'plugins', 'module_utils') + if (Test-Path -LiteralPath $utilPath) { + Get-ChildItem -LiteralPath $utilPath | Where-Object Extension -In '.cs', '.psm1' | + New-HashEntry -PluginBase "ansible_collections.$c.plugins.module_utils" @newHashParams + } + + $modulePath = [Path]::Combine($foundPath, 'plugins', 'modules') + if (Test-Path -LiteralPath $modulePath) { + Get-ChildItem -LiteralPath $modulePath | Where-Object Extension -EQ '.ps1' | + New-HashEntry -PluginBase $c @newHashParams + } + ) + $hashedPaths.AddRange($collectionPwshContent) + } + + if (-not (Test-Path -LiteralPath $metaPath)) { + Write-Verbose "Creating meta path '$metaPath'" + New-Item -Path $metaPath -ItemType Directory -Force | Out-Null + } + + $manifest = @( + '@{' + ' Version = 1' + ' HashList = @(' + foreach ($content in $hashedPaths) { + # To avoid encoding problems with Authenticode and non-ASCII + # characters, we escape them as Unicode code points. We also + # escape some ASCII control characters that can cause escaping + # problems like newlines. + $escapedName = [Regex]::Replace( + $content.Name, + '([^\u0020-\u007F])', + { '\u{0:x4}' -f ([uint16][char]$args[0].Value) }) + + $escapedHash = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Hash) + $escapedMode = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Mode) + " # $escapedName" + " @{" + " Hash = '$escapedHash'" + " Mode = '$escapedMode'" + " }" + } + ' )' + '}' + ) -join "`n" + $manifestPath = [Path]::Combine($metaPath, 'powershell_signatures.psd1') + Write-Verbose "Creating and signing manifest for $c at '$manifestPath'" + Set-Content -LiteralPath $manifestPath -Value $manifest -NoNewline + + Set-OpenAuthenticodeSignature -LiteralPath $manifestPath @signParams + + $pathsToSign | ForEach-Object -Process { + $tempPath = Join-Path $_.DirectoryName "$($_.BaseName)_tmp.ps1" + $_ | Copy-Item -Destination $tempPath -Force + + try { + Write-Verbose "Signing script '$($_.FullName)'" + Set-OpenAuthenticodeSignature -LiteralPath $tempPath @signParams + + $signedContent = Get-Content -LiteralPath $tempPath -Raw + $sigIndex = $signedContent.LastIndexOf("`r`n# SIG # Begin signature block`r`n") + if ($sigIndex -eq -1) { + throw "Failed to find signature block in $($_.FullName)" + } + + # Ignore the first and last \r\n when extracting the signature + $sigIndex += 2 + $signature = $signedContent.Substring($sigIndex, $signedContent.Length - $sigIndex - 2) + $sigPath = Join-Path $_.DirectoryName "$($_.Name).authenticode" + + Write-Verbose "Creating signature file at '$sigPath'" + Set-Content -LiteralPath $sigPath -Value $signature -NoNewline + } + finally { + $tempPath | Remove-Item -Force + } + } + } + catch { + $_.ErrorDetails = "Failed to process collection ${c}: $_" + $PSCmdlet.WriteError($_) + continue + } + } + } + + clean { + foreach ($e in $backupEnv.GetEnumerator()) { + Set-Item -Path "env:$($e.Key)" -Value $e.Value + } + } +}