Add support for Windows App Control/WDAC (#84898)

* Add support for Windows App Control/WDAC

Adds preview support for Windows App Control, formerly known as WDAC.
This is a tech preview feature and is designed to test out improvements
needed in future versions of Ansible.

* Use psd1 and parse it through the Ast to avoid any unexpected execution results

* Add tests for various manifest permutations

* Ignore test shebang failure

* Apply suggestions from code review

Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com>

* Use more flexible test expectations

* Add type annotations for shell functions

---------

Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com>
pull/85193/head
Jordan Borean 7 months ago committed by GitHub
parent e82be177cd
commit 75f7b2267d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

3
.gitignore vendored

@ -97,6 +97,9 @@ Vagrantfile
# vendored lib dir
lib/ansible/_vendor/*
!lib/ansible/_vendor/__init__.py
# PowerShell signed hashlist
lib/ansible/config/powershell_signatures.psd1
*.authenticode
# test stuff
/test/integration/cloud-config-*.*
!/test/integration/cloud-config-*.*.template

@ -0,0 +1,9 @@
minor_changes:
- >-
windows - Added support for ``#AnsibleRequires -Wrapper`` to request a PowerShell module be run through the
execution wrapper scripts without any module utils specified.
- >-
windows - Added support for running signed modules and scripts with a Windows host protected by Windows App
Control/WDAC. This is a tech preview and the interface may be subject to change.
- >-
windows - Script modules will preserve UTF-8 encoding when executing the script.

@ -991,10 +991,8 @@ def _find_module_utils(
module_substyle = 'powershell'
b_module_data = b_module_data.replace(REPLACER_WINDOWS, b'#AnsibleRequires -PowerShell Ansible.ModuleUtils.Legacy')
elif re.search(b'#Requires -Module', b_module_data, re.IGNORECASE) \
or re.search(b'#Requires -Version', b_module_data, re.IGNORECASE)\
or re.search(b'#AnsibleRequires -OSVersion', b_module_data, re.IGNORECASE) \
or re.search(b'#AnsibleRequires -Powershell', b_module_data, re.IGNORECASE) \
or re.search(b'#AnsibleRequires -CSharpUtil', b_module_data, re.IGNORECASE):
or re.search(b'#Requires -Version', b_module_data, re.IGNORECASE) \
or re.search(b'#AnsibleRequires -(OSVersion|PowerShell|CSharpUtil|Wrapper)', b_module_data, re.IGNORECASE):
module_style = 'new'
module_substyle = 'powershell'
elif REPLACER_JSONARGS in b_module_data:

@ -40,7 +40,7 @@ param([ScriptBlock]$ScriptBlock, $Param)
& $ScriptBlock.Ast.GetScriptBlock() @Param
'@).AddParameters(
@{
ScriptBlock = $execInfo.ScriptBlock
ScriptBlock = $execInfo.ScriptInfo.ScriptBlock
Param = $execInfo.Parameters
})

@ -113,7 +113,7 @@ try {
}
$execWrapper = @{
name = 'exec_wrapper-async.ps1'
script = $execAction.Script
script = $execAction.ScriptInfo.Script
params = $execAction.Parameters
} | ConvertTo-Json -Compress -Depth 99
$asyncInput = "$execWrapper`n`0`0`0`0`n$($execAction.InputData)"

@ -7,6 +7,7 @@ using namespace System.Collections
using namespace System.Diagnostics
using namespace System.IO
using namespace System.Management.Automation
using namespace System.Management.Automation.Security
using namespace System.Net
using namespace System.Text
@ -53,7 +54,7 @@ $executablePath = Join-Path -Path $PSHome -ChildPath $executable
$actionInfo = Get-AnsibleExecWrapper -EncodeInputOutput
$bootstrapManifest = ConvertTo-Json -InputObject @{
n = "exec_wrapper-become-$([Guid]::NewGuid()).ps1"
s = $actionInfo.Script
s = $actionInfo.ScriptInfo.Script
p = $actionInfo.Parameters
} -Depth 99 -Compress
@ -68,9 +69,26 @@ $m=foreach($i in $input){
$m=$m|ConvertFrom-Json
$p=@{}
foreach($o in $m.p.PSObject.Properties){$p[$o.Name]=$o.Value}
'@
if ([SystemPolicy]::GetSystemLockdownPolicy() -eq 'Enforce') {
# If we started in CLM we need to execute the script from a file so that
# PowerShell validates our exec_wrapper is trusted and will run in FLM.
$command += @'
$n=Join-Path $env:TEMP $m.n
$null=New-Item $n -Value $m.s -Type File -Force
try{$input|&$n @p}
finally{if(Test-Path -LiteralPath $n){Remove-Item -LiteralPath $n -Force}}
'@
}
else {
# If we started in FLM we pass the script through stdin and execute in
# memory.
$command += @'
$c=[System.Management.Automation.Language.Parser]::ParseInput($m.s,$m.n,[ref]$null,[ref]$null).GetScriptBlock()
$input | & $c @p
$input|&$c @p
'@
}
# Strip out any leading or trailing whitespace and remove empty lines.
$command = @(

@ -18,10 +18,32 @@ foreach ($obj in $code.params.PSObject.Properties) {
$splat[$obj.Name] = $obj.Value
}
$cmd = [System.Management.Automation.Language.Parser]::ParseInput(
$code.script,
"$($code.name).ps1", # Name is used in stack traces.
[ref]$null,
[ref]$null).GetScriptBlock()
$filePath = $null
try {
$cmd = if ($ExecutionContext.SessionState.LanguageMode -eq 'FullLanguage') {
# In FLM we can just invoke the code as a scriptblock without touching the
# disk.
[System.Management.Automation.Language.Parser]::ParseInput(
$code.script,
"$($code.name).ps1", # Name is used in stack traces.
[ref]$null,
[ref]$null).GetScriptBlock()
}
else {
# CLM needs to execute code from a file for it to run in FLM when trusted.
# Set-Item on 5.1 doesn't have a way to use UTF-8 without a BOM but luckily
# New-Item does that by default for both 5.1 and 7. We need to ensure we
# use UTF-8 without BOM so the signature is correct.
$filePath = Join-Path -Path $env:TEMP -ChildPath "$($code.name)-$(New-Guid).ps1"
$null = New-Item -Path $filePath -Value $code.script -ItemType File -Force
$filePath
}
$input | & $cmd @splat
$input | & $cmd @splat
}
finally {
if ($filePath -and (Test-Path -LiteralPath $filePath)) {
Remove-Item -LiteralPath $filePath -Force
}
}

@ -9,6 +9,7 @@ using namespace System.Linq
using namespace System.Management.Automation
using namespace System.Management.Automation.Language
using namespace System.Management.Automation.Security
using namespace System.Reflection
using namespace System.Security.Cryptography
using namespace System.Text
@ -53,6 +54,10 @@ begin {
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
if ($PSCommandPath -and (Test-Path -LiteralPath $PSCommandPath)) {
Remove-Item -LiteralPath $PSCommandPath -Force
}
# Try and set the console encoding to UTF-8 allowing Ansible to read the
# output of the wrapper as UTF-8 bytes.
try {
@ -89,6 +94,9 @@ begin {
}
# $Script:AnsibleManifest = @{} # Defined in process/end.
$Script:AnsibleShouldConstrain = [SystemPolicy]::GetSystemLockdownPolicy() -eq 'Enforce'
$Script:AnsibleTrustedHashList = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$Script:AnsibleUnsupportedHashList = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$Script:AnsibleWrapperWarnings = [List[string]]::new()
$Script:AnsibleTempPath = @(
# Wrapper defined tmpdir
@ -110,6 +118,8 @@ begin {
$false
}
} | Select-Object -First 1
$Script:AnsibleTempScripts = [List[string]]::new()
$Script:AnsibleClrFacadeSet = $false
Function Convert-JsonObject {
param(
@ -147,7 +157,11 @@ begin {
[Parameter()]
[switch]
$IncludeScriptBlock
$IncludeScriptBlock,
[Parameter()]
[switch]
$SkipHashCheck
)
if (-not $Script:AnsibleManifest.scripts.Contains($Name)) {
@ -172,11 +186,93 @@ begin {
[ref]$null).GetScriptBlock()
}
[PSCustomObject]@{
$outputValue = [PSCustomObject]@{
Name = $Name
Script = $scriptContents
Path = $scriptInfo.path
ScriptBlock = $sbk
ShouldConstrain = $false
}
if (-not $Script:AnsibleShouldConstrain) {
$outputValue
return
}
if (-not $SkipHashCheck) {
$sha256 = [SHA256]::Create()
$scriptHash = [BitConverter]::ToString($sha256.ComputeHash($scriptBytes)).Replace("-", "")
$sha256.Dispose()
if ($Script:AnsibleUnsupportedHashList.Contains($scriptHash)) {
$err = [ErrorRecord]::new(
[Exception]::new("Provided script for '$Name' is marked as unsupported in CLM mode."),
"ScriptUnsupported",
[ErrorCategory]::SecurityError,
$Name)
$PSCmdlet.ThrowTerminatingError($err)
}
elseif ($Script:AnsibleTrustedHashList.Contains($scriptHash)) {
$outputValue
return
}
}
# If we have reached here we are running in a locked down environment
# and the script is not trusted in the signed hashlists. Check if it
# contains the authenticode signature and verify that using PowerShell.
# [SystemPolicy]::GetFilePolicyEnforcement(...) is a new API but only
# present in Server 2025+ so we need to rely on the known behaviour of
# Get-Command to fail with CommandNotFoundException if the script is
# not allowed to run.
$outputValue.ShouldConstrain = $true
if ($scriptContents -like "*`r`n# SIG # Begin signature block`r`n*") {
Set-WinPSDefaultFileEncoding
# If the script is manually signed we need to ensure the signature
# is valid and trusted by the OS policy.
# We must use '.ps1' so the ExternalScript WDAC check will apply.
$tmpFile = [Path]::Combine($Script:AnsibleTempPath, "ansible-tmp-$([Guid]::NewGuid()).ps1")
try {
[File]::WriteAllBytes($tmpFile, $scriptBytes)
$cmd = Get-Command -Name $tmpFile -CommandType ExternalScript -ErrorAction Stop
# Get-Command caches the file contents after loading which we
# use to verify it was not modified before the signature check.
$expectedScript = $cmd.OriginalEncoding.GetString($scriptBytes)
if ($expectedScript -ne $cmd.ScriptContents) {
$err = [ErrorRecord]::new(
[Exception]::new("Script has been modified during signature check."),
"ScriptModifiedTrusted",
[ErrorCategory]::SecurityError,
$Name)
$PSCmdlet.ThrowTerminatingError($err)
}
$outputValue.ShouldConstrain = $false
}
catch [CommandNotFoundException] {
$null = $null # No-op but satisfies the linter.
}
finally {
if (Test-Path -LiteralPath $tmpFile) {
Remove-Item -LiteralPath $tmpFile -Force
}
}
}
if ($outputValue.ShouldConstrain -and $IncludeScriptBlock) {
# If the script is untrusted and a scriptblock was requested we
# error out as the sbk would have run in FLM.
$err = [ErrorRecord]::new(
[Exception]::new("Provided script for '$Name' is not trusted to run."),
"ScriptNotTrusted",
[ErrorCategory]::SecurityError,
$Name)
$PSCmdlet.ThrowTerminatingError($err)
}
else {
$outputValue
}
}
@ -223,7 +319,7 @@ begin {
$IncludeScriptBlock
)
$sbk = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
$scriptInfo = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
$params = @{
# TempPath may contain env vars that change based on the runtime
# environment. Ensure we use that and not the $script:AnsibleTempPath
@ -244,8 +340,7 @@ begin {
}
[PSCustomObject]@{
Script = $sbk.Script
ScriptBlock = $sbk.ScriptBlock
ScriptInfo = $scriptInfo
Parameters = $params
InputData = $inputData
}
@ -279,11 +374,16 @@ begin {
$isBasicUtil = $false
$csharpModules = foreach ($moduleName in $Name) {
(Get-AnsibleScript -Name $moduleName).Script
$scriptInfo = Get-AnsibleScript -Name $moduleName
if ($scriptInfo.ShouldConstrain) {
throw "C# module util '$Name' is not trusted and cannot be loaded."
}
if ($moduleName -eq 'Ansible.Basic.cs') {
$isBasicUtil = $true
}
$scriptInfo.Script
}
$fakeModule = [PSCustomObject]@{
@ -303,6 +403,112 @@ begin {
}
}
Function Import-SignedHashList {
[CmdletBinding()]
param (
[Parameter(Mandatory, ValueFromPipeline)]
[string]
$Name
)
process {
try {
# We skip the hash check to ensure we verify based on the
# authenticode signature and not whether it's trusted by an
# existing signed hash list.
$scriptInfo = Get-AnsibleScript -Name $Name -SkipHashCheck
if ($scriptInfo.ShouldConstrain) {
throw "script is not signed or not trusted to run."
}
$hashListAst = [Parser]::ParseInput(
$scriptInfo.Script,
$Name,
[ref]$null,
[ref]$null)
$manifestAst = $hashListAst.Find({ $args[0] -is [HashtableAst] }, $false)
if ($null -eq $manifestAst) {
throw "expecting a single hashtable in the signed manifest."
}
$out = $manifestAst.SafeGetValue()
if (-not $out.Contains('Version')) {
throw "expecting hash list to contain 'Version' key."
}
if ($out.Version -ne 1) {
throw "unsupported hash list Version $($out.Version), expecting 1."
}
if (-not $out.Contains('HashList')) {
throw "expecting hash list to contain 'HashList' key."
}
$out.HashList | ForEach-Object {
if ($_ -isnot [hashtable] -or -not $_.ContainsKey('Hash') -or $_.Hash -isnot [string] -or $_.Hash.Length -ne 64) {
throw "expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings."
}
if ($_.Mode -eq 'Trusted') {
$null = $Script:AnsibleTrustedHashList.Add($_.Hash)
}
elseif ($_.Mode -eq 'Unsupported') {
# Allows us to provide a better error when trying to run
# something in CLM that is marked as unsupported.
$null = $Script:AnsibleUnsupportedHashList.Add($_.Hash)
}
else {
throw "expecting hash list entry for $($_.Hash) to contain a mode of 'Trusted' or 'Unsupported' but got '$($_.Mode)'."
}
}
}
catch {
$_.ErrorDetails = [ErrorDetails]::new("Failed to process signed manifest '$Name': $_")
$PSCmdlet.WriteError($_)
}
}
}
Function New-TempAnsibleFile {
[OutputType([string])]
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$FileName,
[Parameter(Mandatory)]
[string]
$Content
)
$name = [Path]::GetFileNameWithoutExtension($FileName)
$ext = [Path]::GetExtension($FileName)
$newName = "$($name)-$([Guid]::NewGuid())$ext"
$path = Join-Path -Path $Script:AnsibleTempPath $newName
Set-WinPSDefaultFileEncoding
[File]::WriteAllText($path, $Content, [UTF8Encoding]::new($false))
$path
}
Function Set-WinPSDefaultFileEncoding {
[CmdletBinding()]
param ()
# WinPS defaults to the locale encoding when loading a script from the
# file path but in Ansible we expect it to always be UTF-8 without a
# BOM. This lazily sets an internal field so pwsh reads it as UTF-8.
# If we don't do this then scripts saved as UTF-8 on the Ansible
# controller will not run as expected.
if ($PSVersionTable.PSVersion -lt '6.0' -and -not $Script:AnsibleClrFacadeSet) {
$clrFacade = [PSObject].Assembly.GetType('System.Management.Automation.ClrFacade')
$defaultEncodingField = $clrFacade.GetField('_defaultEncoding', [BindingFlags]'NonPublic, Static')
$defaultEncodingField.SetValue($null, [UTF8Encoding]::new($false))
$Script:AnsibleClrFacadeSet = $true
}
}
Function Write-AnsibleErrorJson {
[CmdletBinding()]
param (
@ -414,6 +620,10 @@ begin {
$Script:AnsibleManifest = $Manifest
}
if ($Script:AnsibleShouldConstrain) {
$Script:AnsibleManifest.signed_hashlist | Import-SignedHashList
}
$actionInfo = Get-NextAnsibleAction
$actionParams = $actionInfo.Parameters
@ -500,5 +710,8 @@ end {
}
finally {
$actionPipeline.Dispose()
if ($Script:AnsibleTempScripts) {
Remove-Item -LiteralPath $Script:AnsibleTempScripts -Force -ErrorAction Ignore
}
}
}

@ -30,6 +30,7 @@ from ansible.plugins.loader import ps_module_utils_loader
class _ExecManifest:
scripts: dict[str, _ScriptInfo] = dataclasses.field(default_factory=dict)
actions: list[_ManifestAction] = dataclasses.field(default_factory=list)
signed_hashlist: list[str] = dataclasses.field(default_factory=list)
@dataclasses.dataclass(frozen=True, kw_only=True)
@ -54,6 +55,11 @@ class PSModuleDepFinder(object):
def __init__(self) -> None:
# This is also used by validate-modules to get a module's required utils in base and a collection.
self.scripts: dict[str, _ScriptInfo] = {}
self.signed_hashlist: set[str] = set()
if builtin_hashlist := _get_powershell_signed_hashlist():
self.signed_hashlist.add(builtin_hashlist.path)
self.scripts[builtin_hashlist.path] = builtin_hashlist
self._util_deps: dict[str, set[str]] = {}
@ -119,6 +125,15 @@ class PSModuleDepFinder(object):
lines = module_data.split(b'\n')
module_utils: set[tuple[str, str, bool]] = set()
if fqn and fqn.startswith("ansible_collections."):
submodules = fqn.split('.')
collection_name = '.'.join(submodules[:3])
collection_hashlist = _get_powershell_signed_hashlist(collection_name)
if collection_hashlist and collection_hashlist.path not in self.signed_hashlist:
self.signed_hashlist.add(collection_hashlist.path)
self.scripts[collection_hashlist.path] = collection_hashlist
if powershell:
checks = [
# PS module contains '#Requires -Module Ansible.ModuleUtils.*'
@ -315,6 +330,10 @@ def _bootstrap_powershell_script(
)
)
if hashlist := _get_powershell_signed_hashlist():
exec_manifest.signed_hashlist.append(hashlist.path)
exec_manifest.scripts[hashlist.path] = hashlist
bootstrap_wrapper = _get_powershell_script("bootstrap_wrapper.ps1")
bootstrap_input = _get_bootstrap_input(exec_manifest)
if has_input:
@ -339,6 +358,14 @@ def _get_powershell_script(
if code is None:
raise AnsibleFileNotFound(f"Could not find powershell script '{package_name}.{name}'")
try:
sig_data = pkgutil.get_data(package_name, f"{name}.authenticode")
except FileNotFoundError:
sig_data = None
if sig_data:
code = code + b"\r\n" + b"\r\n".join(sig_data.splitlines()) + b"\r\n"
return code
@ -501,6 +528,7 @@ def _create_powershell_wrapper(
exec_manifest = _ExecManifest(
scripts=finder.scripts,
actions=actions,
signed_hashlist=list(finder.signed_hashlist),
)
return _get_bootstrap_input(
@ -551,3 +579,27 @@ def _prepare_module_args(module_args: dict[str, t.Any], profile: str) -> dict[st
encoder = get_module_encoder(profile, Direction.CONTROLLER_TO_MODULE)
return json.loads(json.dumps(module_args, cls=encoder))
def _get_powershell_signed_hashlist(
collection: str | None = None,
) -> _ScriptInfo | None:
"""Gets the signed hashlist script stored in either the Ansible package or for
the collection specified.
:param collection: The collection namespace to get the signed hashlist for or None for the builtin.
:return: The _ScriptInfo payload of the signed hashlist script if found, None if not.
"""
resource = 'ansible.config' if collection is None else f"{collection}.meta"
signature_file = 'powershell_signatures.psd1'
try:
sig_data = pkgutil.get_data(resource, signature_file)
except FileNotFoundError:
sig_data = None
if sig_data:
resource_path = f"{resource}.{signature_file}"
return _ScriptInfo(content=sig_data, path=resource_path)
return None

@ -97,6 +97,10 @@ $ps = [PowerShell]::Create()
if ($ForModule) {
$ps.Runspace.SessionStateProxy.SetVariable("ErrorActionPreference", "Stop")
}
else {
# For script files we want to ensure we load it as UTF-8
Set-WinPSDefaultFileEncoding
}
foreach ($variable in $Variables) {
$null = $ps.AddCommand("Set-Variable").AddParameters($variable).AddStatement()
@ -112,12 +116,31 @@ foreach ($env in $Environment.GetEnumerator()) {
$null = $ps.AddScript('Function Write-Host($msg) { Write-Output -InputObject $msg }').AddStatement()
$scriptInfo = Get-AnsibleScript -Name $Script
if ($scriptInfo.ShouldConstrain) {
# Fail if there are any module utils, in the future we may allow unsigned
# PowerShell utils in CLM but for now we don't.
if ($PowerShellModules -or $CSharpModules) {
throw "Cannot run untrusted PowerShell script '$Script' in ConstrainedLanguage mode with module util imports."
}
if ($PowerShellModules) {
foreach ($utilName in $PowerShellModules) {
$utilInfo = Get-AnsibleScript -Name $utilName
# If the module is marked as needing to be constrained then we set the
# language mode to ConstrainedLanguage so that when parsed inside the
# Runspace it will run in CLM. We need to run it from a filepath as in
# CLM we cannot call the methods needed to create the ScriptBlock and we
# need to be in CLM to downgrade the language mode.
$null = $ps.AddScript('$ExecutionContext.SessionState.LanguageMode = "ConstrainedLanguage"').AddStatement()
$scriptPath = New-TempAnsibleFile -FileName $Script -Content $scriptInfo.Script
$null = $ps.AddCommand($scriptPath, $false).AddStatement()
}
else {
if ($PowerShellModules) {
foreach ($utilName in $PowerShellModules) {
$utilInfo = Get-AnsibleScript -Name $utilName
if ($utilInfo.ShouldConstrain) {
throw "PowerShell module util '$utilName' is not trusted and cannot be loaded."
}
$null = $ps.AddScript(@'
$null = $ps.AddScript(@'
param ($Name, $Script)
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($Name)
@ -130,32 +153,33 @@ $sbk = [System.Management.Automation.Language.Parser]::ParseInput(
New-Module -Name $moduleName -ScriptBlock $sbk |
Import-Module -WarningAction SilentlyContinue -Scope Global
'@, $true)
$null = $ps.AddParameters(
@{
Name = $utilName
Script = $utilInfo.Script
}
).AddStatement()
$null = $ps.AddParameters(
@{
Name = $utilName
Script = $utilInfo.Script
}
).AddStatement()
}
}
}
if ($CSharpModules) {
# C# utils are process wide so just load them here.
Import-CSharpUtil -Name $CSharpModules
}
if ($CSharpModules) {
# C# utils are process wide so just load them here.
Import-CSharpUtil -Name $CSharpModules
}
# We invoke it through a command with useLocalScope $false to
# ensure the code runs with it's own $script: scope. It also
# cleans up the StackTrace on errors by not showing the stub
# execution line and starts immediately at the module "cmd".
$null = $ps.AddScript(@'
# We invoke it through a command with useLocalScope $false to
# ensure the code runs with it's own $script: scope. It also
# cleans up the StackTrace on errors by not showing the stub
# execution line and starts immediately at the module "cmd".
$null = $ps.AddScript(@'
${function:<AnsibleModule>} = [System.Management.Automation.Language.Parser]::ParseInput(
$args[0],
$args[1],
[ref]$null,
[ref]$null).GetScriptBlock()
'@).AddArgument($scriptInfo.Script).AddArgument($Script).AddStatement()
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
}
if ($Breakpoints) {
$ps.Runspace.Debugger.SetBreakpoints($Breakpoints)

@ -0,0 +1,20 @@
# (c) 2025 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Path
)
$userProfile = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
if ($Path -eq '~') {
$userProfile
}
elseif ($Path.StartsWith(('~\'))) {
Join-Path -Path $userProfile -ChildPath $Path.Substring(2)
}
else {
$Path
}

@ -0,0 +1,17 @@
# (c) 2025 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Directory,
[Parameter(Mandatory)]
[string]
$Name
)
$path = [Environment]::ExpandEnvironmentVariables($Directory)
$tmp = New-Item -Path $path -Name $Name -ItemType Directory
$tmp.FullName

@ -474,8 +474,8 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
become_unprivileged = self._is_become_unprivileged()
basefile = self._connection._shell._generate_temp_dir_name()
cmd = self._connection._shell.mkdtemp(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
result = self._low_level_execute_command(cmd, sudoable=False)
cmd = self._connection._shell._mkdtemp2(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
result = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
# error handling on this seems a little aggressive?
if result['rc'] != 0:
@ -906,8 +906,8 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
expand_path = '~%s' % (self._get_remote_user() or '')
# use shell to construct appropriate command and execute
cmd = self._connection._shell.expand_user(expand_path)
data = self._low_level_execute_command(cmd, sudoable=False)
cmd = self._connection._shell._expand_user2(expand_path)
data = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
try:
initial_fragment = data['stdout'].strip().splitlines()[-1]

@ -723,8 +723,11 @@ class Connection(ConnectionBase):
super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)
encoded_prefix = self._shell._encode_script('', as_list=False, strict_mode=False, preserve_rc=False)
if cmd.startswith(encoded_prefix):
# Avoid double encoding the script
if cmd.startswith(encoded_prefix) or cmd.startswith("type "):
# Avoid double encoding the script, the first means we are already
# running the standard PowerShell command, the latter is used for
# the no pipeline case where it uses type to pipe the script into
# powershell which is known to work without re-encoding as pwsh.
cmd_parts = cmd.split(" ")
else:
cmd_parts = self._shell._encode_script(cmd, as_list=True, strict_mode=False, preserve_rc=False)

@ -16,6 +16,7 @@
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import dataclasses
import os
import os.path
import re
@ -33,6 +34,13 @@ from ansible.plugins import AnsiblePlugin
_USER_HOME_PATH_RE = re.compile(r'^~[_.A-Za-z0-9][-_.A-Za-z0-9]*$')
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
class _ShellCommand:
"""Internal type returned by shell subsystems that may require both an execution payload and a command (eg powershell)."""
command: str
input_data: bytes | None = None
class ShellBase(AnsiblePlugin):
def __init__(self):
@ -121,7 +129,13 @@ class ShellBase(AnsiblePlugin):
cmd = ['test', '-e', self.quote(path)]
return ' '.join(cmd)
def mkdtemp(self, basefile=None, system=False, mode=0o700, tmpdir=None):
def mkdtemp(
self,
basefile: str | None = None,
system: bool = False,
mode: int = 0o700,
tmpdir: str | None = None,
) -> str:
if not basefile:
basefile = self.__class__._generate_temp_dir_name()
@ -163,7 +177,31 @@ class ShellBase(AnsiblePlugin):
return cmd
def expand_user(self, user_home_path, username=''):
def _mkdtemp2(
self,
basefile: str | None = None,
system: bool = False,
mode: int = 0o700,
tmpdir: str | None = None,
) -> _ShellCommand:
"""Gets command info to create a temporary directory.
This is an internal API that should not be used publicly.
:args basefile: The base name of the temporary directory.
:args system: If True, create the directory in a system-wide location.
:args mode: The permissions mode for the directory.
:args tmpdir: The directory in which to create the temporary directory.
:returns: The shell command to run to create the temp directory.
"""
cmd = self.mkdtemp(basefile=basefile, system=system, mode=mode, tmpdir=tmpdir)
return _ShellCommand(command=cmd, input_data=None)
def expand_user(
self,
user_home_path: str,
username: str = '',
) -> str:
""" Return a command to expand tildes in a path
It can be either "~" or "~username". We just ignore $HOME
@ -184,6 +222,22 @@ class ShellBase(AnsiblePlugin):
return 'echo %s' % user_home_path
def _expand_user2(
self,
user_home_path: str,
username: str = '',
) -> _ShellCommand:
"""Gets command to expand user path.
This is an internal API that should not be used publicly.
:args user_home_path: The path to expand.
:args username: The username to use for expansion.
:returns: The shell command to run to get the expanded user path.
"""
cmd = self.expand_user(user_home_path, username=username)
return _ShellCommand(command=cmd, input_data=None)
def pwd(self):
"""Return the working directory after connecting"""
return 'echo %spwd%s' % (self._SHELL_SUB_LEFT, self._SHELL_SUB_RIGHT)

@ -21,9 +21,9 @@ import shlex
import xml.etree.ElementTree as ET
import ntpath
from ansible.executor.powershell.module_manifest import _get_powershell_script
from ansible.executor.powershell.module_manifest import _bootstrap_powershell_script, _get_powershell_script
from ansible.module_utils.common.text.converters import to_bytes, to_text
from ansible.plugins.shell import ShellBase
from ansible.plugins.shell import ShellBase, _ShellCommand
# This is weird, we are matching on byte sequences that match the utf-16-be
# matches for '_x(a-fA-F0-9){4}_'. The \x00 and {4} will match the hex sequence
@ -225,9 +225,15 @@ class ShellModule(ShellBase):
else:
return self._encode_script("""Remove-Item '%s' -Force;""" % path)
def mkdtemp(self, basefile=None, system=False, mode=None, tmpdir=None):
# Windows does not have an equivalent for the system temp files, so
# the param is ignored
def mkdtemp(
self,
basefile: str | None = None,
system: bool = False,
mode: int = 0o700,
tmpdir: str | None = None,
) -> str:
# This is not called in Ansible anymore but it is kept for backwards
# compatibility in case other action plugins outside Ansible calls this.
if not basefile:
basefile = self.__class__._generate_temp_dir_name()
basefile = self._escape(self._unquote(basefile))
@ -241,10 +247,38 @@ class ShellModule(ShellBase):
"""
return self._encode_script(script.strip())
def expand_user(self, user_home_path, username=''):
# PowerShell only supports "~" (not "~username"). Resolve-Path ~ does
# not seem to work remotely, though by default we are always starting
# in the user's home directory.
def _mkdtemp2(
self,
basefile: str | None = None,
system: bool = False,
mode: int = 0o700,
tmpdir: str | None = None,
) -> _ShellCommand:
# Windows does not have an equivalent for the system temp files, so
# the param is ignored
if not basefile:
basefile = self.__class__._generate_temp_dir_name()
basefile = self._unquote(basefile)
basetmpdir = tmpdir if tmpdir else self.get_option('remote_tmp')
script, stdin = _bootstrap_powershell_script("powershell_mkdtemp.ps1", {
'Directory': basetmpdir,
'Name': basefile,
})
return _ShellCommand(
command=self._encode_script(script),
input_data=stdin,
)
def expand_user(
self,
user_home_path: str,
username: str = '',
) -> str:
# This is not called in Ansible anymore but it is kept for backwards
# compatibility in case other actions plugins outside Ansible called this.
user_home_path = self._unquote(user_home_path)
if user_home_path == '~':
script = 'Write-Output (Get-Location).Path'
@ -254,6 +288,21 @@ class ShellModule(ShellBase):
script = "Write-Output '%s'" % self._escape(user_home_path)
return self._encode_script(f"{self._CONSOLE_ENCODING}; {script}")
def _expand_user2(
self,
user_home_path: str,
username: str = '',
) -> _ShellCommand:
user_home_path = self._unquote(user_home_path)
script, stdin = _bootstrap_powershell_script("powershell_expand_user.ps1", {
'Path': user_home_path,
})
return _ShellCommand(
command=self._encode_script(script),
input_data=stdin,
)
def exists(self, path):
path = self._escape(self._unquote(path))
script = """

@ -0,0 +1,5 @@
shippable/windows/group1
shippable/windows/smoketest
needs/target/collection
needs/target/setup_remote_tmp_dir
destructive # modifies files in the ansible installation dir

@ -0,0 +1,12 @@
using System;
namespace ansible_collections.ns.col.plugins.module_utils.CSharpSigned
{
public class TestClass
{
public static string TestMethod(string input)
{
return input;
}
}
}

@ -0,0 +1,12 @@
using System;
namespace ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned
{
public class TestClass
{
public static string TestMethod(string input)
{
return input;
}
}
}

@ -0,0 +1,14 @@
Function Test-PwshSigned {
<#
.SYNOPSIS
Tests a signed collection pwsh util.
#>
[CmdletBinding()]
param ()
@{
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
}
}
Export-ModuleMember -Function Test-PwshSigned

@ -0,0 +1,14 @@
Function Test-PwshUnsigned {
<#
.SYNOPSIS
Tests an unsigned collection pwsh util.
#>
[CmdletBinding()]
param ()
@{
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
}
}
Export-ModuleMember -Function Test-PwshUnsigned

@ -0,0 +1,10 @@
#!powershell
#AnsibleRequires -Wrapper
@{
test = 'inline_signed'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $complex_args.input
} | ConvertTo-Json

@ -0,0 +1,10 @@
#!powershell
#AnsibleRequires -Wrapper
@{
test = 'inline_signed_not_trusted'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $complex_args.input
} | ConvertTo-Json

@ -0,0 +1,29 @@
#!powershell
using namespace Ansible.Basic
using namespace System.Management.Automation.Language
using namespace Invalid.Namespace.That.Does.Not.Exist
#AnsibleRequires -CSharpUtil Ansible.Basic
$module = [AnsibleModule]::Create($args, @{ options = @{} })
$module.Result.module_using_namespace = [Parser].FullName
# Verifies the module is run in its own script scope
$var = 'foo'
$module.Result.script_var = $script:var
$missingUsingNamespace = $false
try {
# exec_wrapper does 'using namespace System.IO'. This ensures that this
# hasn't persisted to the module scope and it has it's own set of using
# types.
$null = [File]::Exists('test')
}
catch {
$missingUsingNamespace = $true
}
$module.Result.missing_using_namespace = $missingUsingNamespace
$module.ExitJson()

@ -0,0 +1,10 @@
#!powershell
#AnsibleRequires -Wrapper
@{
test = 'signed'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $complex_args.input
} | ConvertTo-Json

@ -0,0 +1,33 @@
#!powershell
#AnsibleRequires -CSharpUtil Ansible.Basic
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
# Tests builtin C# util
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
# Tests builtin pwsh util
Add-CSharpType -AnsibleModule $module -References @'
using System;
namespace ns.col.module_utils
{
public class InlineCSharp
{
public static string TestMethod(string input)
{
return input;
}
}
}
'@
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
$module.Result.builtin_powershell_util = [ns.col.module_utils.InlineCSharp]::TestMethod("value")
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
$module.Result.powershell_util = Test-PwshSigned
$module.ExitJson()

@ -0,0 +1,14 @@
#!powershell
#AnsibleRequires -Wrapper
if ($complex_args.should_fail) {
throw "exception here"
}
@{
test = 'skipped'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $complex_args.input
} | ConvertTo-Json

@ -0,0 +1,9 @@
#!powershell
#AnsibleRequires -CSharpUtil ..module_utils.CSharpUnsigned
@{
changed = $false
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
res = [ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned.TestClass]::TestMethod("value")
} | ConvertTo-Json

@ -0,0 +1,15 @@
#!powershell
#AnsibleRequires -CSharpUtil Ansible.Basic
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
@{
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
builtin_csharp = $null -ne ('Ansible.Basic.AnsibleModule' -as [type])
builtin_pwsh = [bool](Get-Command Add-CSharpType -ErrorAction SilentlyContinue)
collection_csharp = $null -ne ('ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass' -as [type])
collection_pwsh = [bool](Get-Command Test-PwshSigned -ErrorAction SilentlyContinue)
} | ConvertTo-Json

@ -0,0 +1,9 @@
#!powershell
#AnsibleRequires -PowerShell ..module_utils.PwshUnsigned
@{
changed = $false
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
res = Test-PwshUnsigned
} | ConvertTo-Json

@ -0,0 +1,9 @@
#!powershell
#AnsibleRequires -Wrapper
@{
test = 'unsupported'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
} | ConvertTo-Json

@ -0,0 +1,5 @@
@{
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $args[0]
} | ConvertTo-Json

@ -0,0 +1,5 @@
@{
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $args[0]
} | ConvertTo-Json

@ -0,0 +1,7 @@
- name: run signed script
script: signed.ps1 café
register: signed_res
- name: run unsigned script
script: unsigned.ps1 café
register: unsigned_res

@ -0,0 +1,10 @@
#!powershell
#AnsibleRequires -Wrapper
@{
test = 'ns.invalid_manifest.module'
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
whoami = [Environment]::UserName
ünicode = $complex_args.input
} | ConvertTo-Json

@ -0,0 +1,15 @@
#!powershell
#AnsibleRequires -CSharpUtil Ansible.Basic
#AnsibleRequires -CSharpUtil ansible_collections.ns.col.plugins.module_utils.CSharpSigned
#AnsibleRequires -PowerShell ansible_collections.ns.col.plugins.module_utils.PwshSigned
# Tests signed util in another trusted collection works
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
$module.Result.powershell_util = Test-PwshSigned
$module.ExitJson()

@ -0,0 +1,15 @@
- name: create manifest file
ansible.builtin.template:
src: '{{ manifest_file }}'
dest: '{{ local_tmp_dir }}/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1'
delegate_to: localhost
- name: sign manifest file
ansible.builtin.script: >-
Set-ManifestSignature.ps1
-Path {{ local_tmp_dir ~ "/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1" | quote }}
-CertPath {{ local_tmp_dir ~ "/" ~ (cert_name | default("wdac-signing")) ~ ".pfx" | quote }}
-CertPass {{ cert_pw | quote }}
environment:
NO_COLOR: '1'
delegate_to: localhost

@ -0,0 +1,419 @@
#!/usr/bin/env pwsh
# 0.5.0 fixed BOM-less encoding issues with Unicode
#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' }
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
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$CollectionPath,
[Parameter(Mandatory)]
[string]
$CertPath,
[Parameter(Mandatory)]
[string]
$UntrustedCertPath,
[Parameter(Mandatory)]
[string]
$CertPass
)
$ErrorActionPreference = 'Stop'
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.
.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 overriden 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 ambigious, 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. List -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 {
Write-Verbose "Attempting to get ansible-config dump"
$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 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
}
}
}
}
$cert = [X509Certificate2]::new($CertPath, $CertPass)
$untrustedCert = [X509Certificate2]::new($UntrustedCertPath, $CertPass)
$sigParams = @{
Certificate = $cert
Collection = 'ansible.builtin', 'ansible.windows', 'ns.col', 'ns.module_util_ref'
Skip = @(
'ns.col.skipped'
'ns.col.inline_signed'
'ns.col.inline_signed_not_trusted'
'ns.col.unsigned_module_with_util'
'ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned'
'ansible_collections.ns.col.plugins.module_utils.PwshUnsigned'
)
Unsupported = 'ns.col.unsupported'
}
New-AnsiblePowerShellSignature @sigParams
@(
"$CollectionPath/plugins/modules/inline_signed.ps1"
"$CollectionPath/roles/app_control_script/files/signed.ps1"
) | Set-OpenAuthenticodeSignature -Certificate $cert -HashAlgorithm SHA256
@(
"$CollectionPath/plugins/modules/inline_signed_not_trusted.ps1"
) | Set-OpenAuthenticodeSignature -Certificate $untrustedCert -HashAlgorithm SHA256

@ -0,0 +1,26 @@
#!/usr/bin/env pwsh
# 0.5.0 fixed BOM-less encoding issues with Unicode
#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' }
using namespace System.Security.Cryptography.X509Certificates
[CmdletBinding()]
param (
[Parameter(Mandatory)]
[string]
$Path,
[Parameter(Mandatory)]
[string]
$CertPath,
[Parameter(Mandatory)]
[string]
$CertPass
)
$ErrorActionPreference = 'Stop'
$cert = [X509Certificate2]::new($CertPath, $CertPass)
Set-OpenAuthenticodeSignature -FilePath $Path -Certificate $cert -HashAlgorithm SHA256

@ -0,0 +1,146 @@
- name: run App Control tests on Windows
hosts: windows
gather_facts: false
collections:
# This is important so that the temp ansible.windows is chosen over the ansible-test path
# which will not be signed
- ansible.windows
handlers:
- name: remove openauthenticode
shell: Uninstall-PSResource -Name OpenAuthenticode -Version 0.6.1
args:
executable: pwsh
delegate_to: localhost
tasks:
- name: make sure expected facts are set
assert:
that:
- ansible_install_dir is defined
- local_tmp_dir is defined
- name: get OS version
win_shell: (Get-Item -LiteralPath $env:SystemRoot\System32\kernel32.dll).VersionInfo.ProductVersion.ToString()
register: os_version
- name: setup and test block for 2019 and later
when:
- os_version.stdout | trim is version('10.0.17763', '>=') # 2019+
block:
- name: get test remote tmp dir
import_role:
name: ../setup_remote_tmp_dir
- name: get current user
win_shell: '[Environment]::UserName'
register: current_user_raw
- name: set current user fact
set_fact:
current_user: '{{ current_user_raw.stdout | trim }}'
- name: setup App Control
import_tasks: setup.yml
- name: run content before enabling App Control
import_tasks: test_not_enabled.yml
- name: enable App Control
win_shell: |
$ErrorActionPreference = 'Stop'
$tmpPath = '{{ remote_tmp_dir }}'
$policyPath = Join-Path $tmpPath policy.xml
$certPath = Join-Path $tmpPath signing.cer
$policyName = 'Ansible_AppControl_Test'
Copy-Item "$env:windir\schemas\CodeIntegrity\ExamplePolicies\DefaultWindows_Enforced.xml" $policyPath
Set-CIPolicyIdInfo -FilePath $policyPath -PolicyName $policyName -PolicyId (New-Guid)
Set-CIPolicyVersion -FilePath $policyPath -Version "1.0.0.0"
Add-SignerRule -FilePath $policyPath -CertificatePath $certPath -User
Set-RuleOption -FilePath $policyPath -Option 0 # Enabled:UMCI
Set-RuleOption -FilePath $policyPath -Option 3 -Delete # Enabled:Audit Mode
Set-RuleOption -FilePath $policyPath -Option 11 -Delete # Disabled:Script Enforcement
Set-RuleOption -FilePath $policyPath -Option 19 # Enabled:Dynamic Code Security
# Using $tmpPath has this step fail
$policyBinPath = "$env:windir\System32\CodeIntegrity\SiPolicy.p7b"
$null = ConvertFrom-CIPolicy -XmlFilePath $policyPath -BinaryFilePath $policyBinPath
$ciTool = Get-Command -Name CiTool.exe -ErrorAction SilentlyContinue
$policyId = $null
if ($ciTool) {
$setInfo = & $ciTool --update-policy $policyBinPath *>&1
if ($LASTEXITCODE) {
throw "citool.exe --update-policy failed ${LASTEXITCODE}: $setInfo"
}
$policyId = & $ciTool --list-policies --json |
ConvertFrom-Json |
Select-Object -ExpandProperty Policies |
Where-Object FriendlyName -eq $policyName |
Select-Object -ExpandProperty PolicyID
}
else {
$rc = Invoke-CimMethod -Namespace root\Microsoft\Windows\CI -ClassName PS_UpdateAndCompareCIPolicy -MethodName Update -Arguments @{
FilePath = $policyBinPath
}
if ($rc.ReturnValue) {
throw "PS_UpdateAndCompareCIPolicy Update failed $($rc.ReturnValue)"
}
}
@{
policy_id = $policyId
path = $policyBinPath
} | ConvertTo-Json
register: policy_info_raw
- name: set policy info fact
set_fact:
policy_info: '{{ policy_info_raw.stdout | from_json }}'
- name: run content after enabling App Control
import_tasks: test_enabled.yml
- name: run invalid manifest tests
import_tasks: test_manifest.yml
always:
- name: disable policy through CiTool if present
win_shell: CiTool.exe --remove-policy {{ policy_info.policy_id }}
when:
- policy_info is defined
- policy_info.policy_id is truthy
- name: remove App Control policy
win_file:
path: '{{ policy_info.path }}'
state: absent
register: policy_removal
when:
- policy_info is defined
- name: reboot after removing policy file
win_reboot:
when: policy_removal is changed
- name: remove certificates
win_shell: |
$ErrorActionPreference = 'Stop'
Remove-Item -LiteralPath 'Cert:\LocalMachine\Root\{{ cert_info.ca_thumbprint }}' -Force
Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPublisher\{{ cert_info.thumbprint }}' -Force
when: cert_info is defined
- name: remove signed Ansible content
file:
path: '{{ ansible_install_dir }}/{{ item }}'
state: absent
loop:
- config/powershell_signatures.psd1
- executor/powershell/exec_wrapper.ps1.authenticode
delegate_to: localhost

@ -0,0 +1,18 @@
#!/usr/bin/env bash
if ! command -V pwsh; then
echo "skipping test since pwsh is not available"
exit 0
fi
source ../collection/setup.sh
set -x
ANSIBLE_DIR="$( python -c "import pathlib, ansible; print(pathlib.Path(ansible.__file__).parent)" )"
ANSIBLE_COLLECTIONS_PATH="${WORK_DIR}" ANSIBLE_DISPLAY_TRACEBACK=error \
ansible-playbook "${TEST_DIR}/main.yml" \
--inventory "${TEST_DIR}/../../inventory.winrm" \
--extra-vars "local_tmp_dir=${WORK_DIR} ansible_install_dir=${ANSIBLE_DIR}" \
"${@}"

@ -0,0 +1,116 @@
- name: setup test facts
set_fact:
cert_pw: "{{ 'password123!' + lookup('password', '/dev/null chars=ascii_letters,digits length=8') }}"
- name: setup WDAC certificates
win_shell: |
$ErrorActionPreference = 'Stop'
$testPrefix = 'Ansible-WDAC'
$certPassword = ConvertTo-SecureString -String '{{ cert_pw }}' -Force -AsPlainText
$remoteTmpDir = '{{ remote_tmp_dir }}'
$enhancedKeyUsage = [Security.Cryptography.OidCollection]::new()
$null = $enhancedKeyUsage.Add('1.3.6.1.5.5.7.3.3') # Code Signing
$caParams = @{
Extension = @(
[Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true),
[Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new('KeyCertSign', $false),
[Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension ]::new($enhancedKeyUsage, $false)
)
CertStoreLocation = 'Cert:\CurrentUser\My'
NotAfter = (Get-Date).AddDays(1)
Type = 'Custom'
}
$ca = New-SelfSignedCertificate @caParams -Subject "CN=$testPrefix-Root"
$certParams = @{
CertStoreLocation = 'Cert:\CurrentUser\My'
KeyUsage = 'DigitalSignature'
TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
Type = 'Custom'
}
$cert = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Signed" -Signer $ca
$null = $cert | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\signing.pfx"
$cert.Export('Cert') | Set-Content -LiteralPath "$remoteTmpDir\signing.cer" -Encoding Byte
$certUntrusted = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Untrusted"
$null = $certUntrusted | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\untrusted.pfx"
$caWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ca.Export('Cert'))
$certWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.Export('Cert'))
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($ca.Thumbprint)" -DeleteKey -Force
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey -Force
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certUntrusted.Thumbprint)" -DeleteKey -Force
$root = Get-Item Cert:\LocalMachine\Root
$root.Open('ReadWrite')
$root.Add($caWithoutKey)
$root.Dispose()
$trustedPublisher = Get-Item Cert:\LocalMachine\TrustedPublisher
$trustedPublisher.Open('ReadWrite')
$trustedPublisher.Add($certWithoutKey)
$trustedPublisher.Dispose()
@{
ca_thumbprint = $caWithoutKey.Thumbprint
thumbprint = $certWithoutKey.Thumbprint
untrusted_thumbprint = $certUntrusted.Thumbprint
} | ConvertTo-Json
register: cert_info_raw
become: true
become_method: runas
vars:
ansible_become_user: '{{ ansible_user }}'
ansible_become_pass: '{{ ansible_password | default(ansible_test_connection_password) }}'
- name: parse raw cert_info
set_fact:
cert_info: "{{ cert_info_raw.stdout | from_json }}"
- name: fetch signing certificates
fetch:
src: '{{ remote_tmp_dir }}\{{ item }}.pfx'
dest: '{{ local_tmp_dir }}/wdac-{{ item }}.pfx'
flat: yes
loop:
- signing
- untrusted
- name: install OpenAuthenticode
shell: |
if (-not (Get-Module -Name OpenAuthenticode -ListAvailable | Where-Object Version -ge '0.5.0')) {
$url = 'https://ansible-ci-files.s3.us-east-1.amazonaws.com/test/integration/targets/win_app_control/openauthenticode.0.6.1.nupkg'
Invoke-WebRequest -Uri $url -OutFile '{{ local_tmp_dir }}/openauthenticode.0.6.1.nupkg'
Register-PSResourceRepository -Name AnsibleTemp -Trusted -Uri '{{ local_tmp_dir }}'
try {
Install-PSResource -Name OpenAuthenticode -Repository AnsibleTemp
} finally {
Unregister-PSResourceRepository -Name AnsibleTemp
}
$true
} else {
$false
}
args:
executable: pwsh
register: open_auth_install
changed_when: open_auth_install.stdout | bool
notify: remove openauthenticode
delegate_to: localhost
- name: sign Ansible content
script: >-
New-AnsiblePowerShellSignature.ps1
-CollectionPath {{ local_tmp_dir ~ "/ansible_collections/ns/col" | quote }}
-CertPath {{ local_tmp_dir ~ "/wdac-signing.pfx" | quote }}
-UntrustedCertPath {{ local_tmp_dir ~ "/wdac-untrusted.pfx" | quote }}
-CertPass {{ cert_pw | quote }}
-Verbose
environment:
NO_COLOR: '1'
delegate_to: localhost

@ -0,0 +1,9 @@
@{
Version = 2
HashList = @(
@{
Hash = '{{ module_hash }}'
Mode = 'Trusted'
}
)
}

@ -0,0 +1,8 @@
@{
HashList = @(
@{
Hash = '{{ module_hash }}'
Mode = 'Trusted'
}
)
}

@ -0,0 +1,9 @@
@{
Version = 1
HashList = @(
@{
Hash = '{{ module_hash }} - extra'
Mode = 'Trusted'
}
)
}

@ -0,0 +1,9 @@
@{
Version = 1
HashList = @(
@{
Hash = '{{ module_hash }}'
Mode = 'Other'
}
)
}

@ -0,0 +1,8 @@
@{
Version = 1
HashList = @(
@{
Mode = 'Trusted'
}
)
}

@ -0,0 +1,8 @@
@{
Version = 1
HashList = @(
@{
Hash = '{{ module_hash }}'
}
)
}

@ -0,0 +1,9 @@
@{
Version = 1
HashList = @(
@{
Hash = '{{ module_hash }}'
Mode = 'Trusted'
}
)
}

@ -0,0 +1,9 @@
@{
Version = 1
HashList = @(
@{
Hash = "$env:TEST_HASH"
Mode = 'Trusted'
}
)
}

@ -0,0 +1,215 @@
- name: run signed module
ns.col.signed:
input: café
register: signed_res
- name: assert run signed module
assert:
that:
- signed_res.language_mode == 'FullLanguage'
- signed_res.test == 'signed'
- signed_res.whoami == current_user
- signed_res.ünicode == 'café'
- name: run inline signed module
ns.col.inline_signed:
input: café
register: inline_signed_res
- name: assert run inline signed module
assert:
that:
- inline_signed_res.language_mode == 'FullLanguage'
- inline_signed_res.test == 'inline_signed'
- inline_signed_res.whoami == current_user
- inline_signed_res.ünicode == 'café'
- name: run inline signed module that is not trusted
ns.col.inline_signed_not_trusted:
input: café
register: inline_signed_not_trusted_res
- name: assert run inline signed module
assert:
that:
- inline_signed_not_trusted_res.language_mode == 'ConstrainedLanguage'
- inline_signed_not_trusted_res.test == 'inline_signed_not_trusted'
- inline_signed_not_trusted_res.whoami == current_user
- inline_signed_not_trusted_res.ünicode == 'café'
- name: run signed module to test exec wrapper scope
ns.col.scope:
register: scoped_res
- name: assert run signed module to test exec wrapper scope
assert:
that:
- scoped_res.missing_using_namespace == True
- scoped_res.module_using_namespace == 'System.Management.Automation.Language.Parser'
- scoped_res.script_var == 'foo'
- name: run module marked as skipped
ns.col.skipped:
input: café
register: skipped_res
- name: assert run module marked as skipped
assert:
that:
- skipped_res.language_mode == 'ConstrainedLanguage'
- skipped_res.test == 'skipped'
- skipped_res.whoami == current_user
- skipped_res.ünicode == 'café'
- name: run module marked as skipped with a failure
ns.col.skipped:
should_fail: true
register: skipped_fail_res
ignore_errors: true
- name: assert run module marked as skipped with a failure
assert:
that:
- skipped_fail_res is failed
- >-
skipped_fail_res.msg == "Unhandled exception while executing module: exception here"
- skipped_fail_res.exception is search("At .*\\\\ansible_collections\.ns\.col\.plugins\.modules\.skipped-.*\.ps1")
- name: run module marked as unsupported
ns.col.unsupported:
register: unsupported_res
failed_when:
- unsupported_res.failed == False
- >-
unsupported_res.msg is not contains("Provided script for 'ansible_collections.ns.col.plugins.modules.unsupported.ps1' is marked as unsupported in CLM mode.")
- name: run module with signed utils
ns.col.signed_module_util:
register: signed_util_res
- name: assert run module with signed utils
assert:
that:
- signed_util_res.language_mode == 'FullLanguage'
- signed_util_res.builtin_powershell_util == 'value'
- signed_util_res.csharp_util == 'value'
- signed_util_res.powershell_util.language_mode == 'FullLanguage'
- name: run module with unsigned C# util
ns.col.unsigned_csharp_util:
register: unsigned_csharp_util_res
failed_when:
- unsigned_csharp_util_res.failed == False
- >-
unsigned_csharp_util_res.msg is not contains("C# module util 'ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned.cs' is not trusted and cannot be loaded.")
- name: run module with unsigned Pwsh util
ns.col.unsigned_pwsh_util:
register: unsigned_pwsh_util_res
failed_when:
- unsigned_pwsh_util_res.failed == False
- >-
unsigned_pwsh_util_res.msg is not contains("PowerShell module util 'ansible_collections.ns.col.plugins.module_utils.PwshUnsigned.cs' is not trusted and cannot be loaded.")
- name: run unsigned module with utils
ns.col.unsigned_module_with_util:
register: unsigned_module_with_util_res
failed_when:
- unsigned_module_with_util_res.failed == False
- >-
unsigned_module_with_util_res.msg is not contains("Cannot run untrusted PowerShell script 'ns.col.unsigned_with_util.ps1' in ConstrainedLanguage mode with module util imports.")
- name: run script role
import_role:
name: ns.col.app_control_script
- name: assert script role result
assert:
that:
- signed_res.rc == 0
- (signed_res.stdout | from_json).language_mode == 'FullLanguage'
- (signed_res.stdout | from_json).whoami == current_user
- (signed_res.stdout | from_json).ünicode == 'café'
- unsigned_res.rc == 0
- (unsigned_res.stdout | from_json).language_mode == 'ConstrainedLanguage'
- (unsigned_res.stdout | from_json).whoami == current_user
- (unsigned_res.stdout | from_json).ünicode == 'café'
- name: signed module with become
ns.col.signed:
input: café
become: true
become_method: runas
become_user: SYSTEM
register: become_res
- name: assert run signed module with become
assert:
that:
- become_res.language_mode == 'FullLanguage'
- become_res.test == 'signed'
- become_res.whoami == 'SYSTEM'
- become_res.ünicode == 'café'
- name: signed module with async
ns.col.signed:
input: café
async: 60
poll: 3
register: async_res
- name: assert run signed module with async
assert:
that:
- async_res.language_mode == 'FullLanguage'
- async_res.test == 'signed'
- async_res.whoami == current_user
- async_res.ünicode == 'café'
- name: copy file
win_copy:
src: New-AnsiblePowerShellSignature.ps1
dest: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
register: copy_res
- name: get remote hash of copied file
win_stat:
path: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
get_checksum: true
register: copy_stat
- name: assert copy file
assert:
that:
- copy_res.checksum == copy_stat.stat.checksum
- name: fetch file
fetch:
src: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
dest: '{{ local_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
flat: true
register: fetch_res
- name: get local hash of fetch file
stat:
path: '{{ local_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
get_checksum: true
delegate_to: localhost
register: fetch_stat
- name: assert fetch file
assert:
that:
- fetch_res.checksum == copy_stat.stat.checksum
- fetch_res.checksum == fetch_stat.stat.checksum
- name: run signed module with signed module util in another collection
ns.module_util_ref.module:
register: cross_util_res
- name: assert run signed module with signed module utils in another collection
assert:
that:
- cross_util_res.language_mode == 'FullLanguage'
- cross_util_res.csharp_util == 'value'
- "cross_util_res.powershell_util == {'language_mode': 'FullLanguage'}"

@ -0,0 +1,154 @@
# These tests verify various failure conditions that will invalidate a signed manifest
- name: get hash of collection module
ansible.builtin.stat:
path: '{{ local_tmp_dir }}/ansible_collections/ns/invalid_manifest/plugins/modules/module.ps1'
get_checksum: true
checksum_algorithm: sha256
delegate_to: localhost
register: module_hash_raw
- name: set module hash var
ansible.builtin.set_fact:
module_hash: '{{ module_hash_raw.stat.checksum | upper }}'
- name: create manifest with untrusted signature
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_ok.psd1
cert_name: wdac-untrusted
- name: run module with untrusted signed manifest
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': script is not signed or not trusted to run.")
- name: create manifest with no Hashtable
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_no_hashtable.psd1
- name: run module with no Hashtable
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting a single hashtable in the signed manifest.")
- name: create manifest with no Version
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_no_version.psd1
- name: run module with no Version
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain 'Version' key.")
- name: create manifest with invalid Version
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_invalid_version.psd1
- name: run module with invalid Version
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': unsupported hash list Version 2, expecting 1.")
- name: create manifest with no HashList
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_no_hashlist.psd1
- name: run module with no HashList
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain 'HashList' key.")
- name: create manifest with no Hash subkey
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_no_hash_subkey.psd1
- name: run module with no Hash subkey
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings.")
- name: create manifest with invalid Hash subkey value
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_invalid_hash_subkey.psd1
- name: run module with invalid Hash subkey value
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings.")
- name: create manifest with no Mode subkey
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_no_mode_subkey.psd1
- name: run module with no Mode subkey
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list entry for " ~ module_hash ~ " to contain a mode of 'Trusted' or 'Unsupported' but got ''.")
- name: create manfiest with invalid Mode subkey value
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_invalid_mode_subkey.psd1
- name: run module with invalid Mode subkey value
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list entry for " ~ module_hash ~ " to contain a mode of 'Trusted' or 'Unsupported' but got 'Other'.")
- name: create manifest with unsafe expressions
ansible.builtin.import_tasks: create_manifest.yml
vars:
manifest_file: manifest_v1_unsafe_expression.psd1
- name: run module with unsafe expressions
ns.invalid_manifest.module:
input: café
register: res
failed_when:
- res.failed == False
- >-
res.msg is not search("failure during exec_wrapper: Failed to process signed manifest 'ansible_collections\.ns\.invalid_manifest\.meta\.powershell_signatures.psd1':.*Cannot generate a Windows PowerShell object for a ScriptBlock evaluating dynamic expressions")

@ -0,0 +1,148 @@
# When App Control is not enabled we expect modules even if signed or unsigned should
# run without any changes in FullLanguageMode.
- name: run signed module
ns.col.signed:
input: café
register: signed_res
- name: assert run signed module
assert:
that:
- signed_res.language_mode == 'FullLanguage'
- signed_res.test == 'signed'
- signed_res.whoami == current_user
- signed_res.ünicode == 'café'
- name: run inline signed module
ns.col.inline_signed:
input: café
register: inline_signed_res
- name: assert run inline signed module
assert:
that:
- inline_signed_res.language_mode == 'FullLanguage'
- inline_signed_res.test == 'inline_signed'
- inline_signed_res.whoami == current_user
- inline_signed_res.ünicode == 'café'
- name: run inline signed module that is not trusted
ns.col.inline_signed_not_trusted:
input: café
register: inline_signed_not_trusted_res
- name: assert run inline signed module
assert:
that:
- inline_signed_not_trusted_res.language_mode == 'FullLanguage'
- inline_signed_not_trusted_res.test == 'inline_signed_not_trusted'
- inline_signed_not_trusted_res.whoami == current_user
- inline_signed_not_trusted_res.ünicode == 'café'
- name: run signed module to test exec wrapper scope
ns.col.scope:
register: scoped_res
- name: assert run signed module to test exec wrapper scope
assert:
that:
- scoped_res.missing_using_namespace == True
- scoped_res.module_using_namespace == 'System.Management.Automation.Language.Parser'
- scoped_res.script_var == 'foo'
- name: run module marked as skipped
ns.col.skipped:
input: café
register: skipped_res
- name: assert run module marked as skipped
assert:
that:
- skipped_res.language_mode == 'FullLanguage'
- skipped_res.test == 'skipped'
- skipped_res.whoami == current_user
- skipped_res.ünicode == 'café'
- name: run module marked as unsupported
ns.col.unsupported:
register: unsupported_res
- name: assert run module marked as unsupported
assert:
that:
- unsupported_res.language_mode == 'FullLanguage'
- unsupported_res.test == 'unsupported'
- unsupported_res.whoami == current_user
- name: run module with signed utils
ns.col.signed_module_util:
register: signed_util_res
- name: assert run module with signed utils
assert:
that:
- signed_util_res.language_mode == 'FullLanguage'
- signed_util_res.builtin_powershell_util == 'value'
- signed_util_res.csharp_util == 'value'
- signed_util_res.powershell_util.language_mode == 'FullLanguage'
- name: run module with unsigned C# util
ns.col.unsigned_csharp_util:
register: unsigned_csharp_util_res
- name: assert run module with unsigned C# util
assert:
that:
- unsigned_csharp_util_res.language_mode == 'FullLanguage'
- unsigned_csharp_util_res.res == 'value'
- name: run module with unsigned Pwsh util
ns.col.unsigned_pwsh_util:
register: unsigned_pwsh_util_res
- name: assert run module with unsigned Pwsh util
assert:
that:
- unsigned_pwsh_util_res.language_mode == 'FullLanguage'
- unsigned_pwsh_util_res.res.language_mode == 'FullLanguage'
- name: run unsigned module with utils
ns.col.unsigned_module_with_util:
register: unsigned_module_with_util_res
- name: assert run unsigned module with utils
assert:
that:
- unsigned_module_with_util_res.language_mode == 'FullLanguage'
- unsigned_module_with_util_res.builtin_csharp == True
- unsigned_module_with_util_res.builtin_pwsh == True
- unsigned_module_with_util_res.collection_csharp == True
- unsigned_module_with_util_res.collection_pwsh == True
- name: run script role
import_role:
name: ns.col.app_control_script
- name: assert script role result
assert:
that:
- signed_res.rc == 0
- (signed_res.stdout | from_json).language_mode == 'FullLanguage'
- (signed_res.stdout | from_json).whoami == current_user
- (signed_res.stdout | from_json).ünicode == 'café'
- unsigned_res.rc == 0
- (unsigned_res.stdout | from_json).language_mode == 'FullLanguage'
- (unsigned_res.stdout | from_json).whoami == current_user
- (unsigned_res.stdout | from_json).ünicode == 'café'
- name: run signed module with signed module util in another collection
ns.module_util_ref.module:
register: cross_util_res
- name: assert run signed module with signed module utils in another collection
assert:
that:
- cross_util_res.language_mode == 'FullLanguage'
- cross_util_res.csharp_util == 'value'
- "cross_util_res.powershell_util == {'language_mode': 'FullLanguage'}"

@ -0,0 +1,8 @@
#!powershell
#AnsibleRequires -Wrapper
@{
changed = $false
complex_args = $complex_args
} | ConvertTo-Json -Depth 99

@ -328,3 +328,14 @@
- exec_wrapper_scope.util_res.module_using_namespace == 'System.Security.Cryptography.X509Certificates.X509Certificate2'
- exec_wrapper_scope.util_res.missing_using_namespace == True
- exec_wrapper_scope.util_res.script_var == 'bar'
- name: test module without any util references
test_no_utils:
foo: bar
register: no_utils_res
- name: assert test module without any util references
assert:
that:
- no_utils_res is not changed
- "no_utils_res.complex_args == {'foo': 'bar'}"

@ -338,3 +338,12 @@
# SSH includes debug output in stderr, and WinRM on 2016 includes a trailing newline
# Use a simple search to ensure the expected stderr is present but ignoring any extra output
- script_stderr.stderr is search("stderr 1\r\nstderr 2\r\n")
- name: run script with non-ASCII contents
script: test_script_unicode.ps1
register: script_unicode
- name: assert run script with non-ASCII contents
assert:
that:
- script_unicode.stdout | trim == 'ünicode'

@ -99,6 +99,9 @@ test/integration/targets/template/templates/encoding_1252.j2 no-smart-quotes
test/integration/targets/template/templates/encoding_1252.j2 no-unwanted-characters
test/integration/targets/unicode/unicode.yml no-smart-quotes
test/integration/targets/windows-minimal/library/win_ping_syntax_error.ps1 pslint!skip
test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 pslint:PSCustomUseLiteralPath # We want to use wildcard matching with -Path
test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 shebang # We want to run with pwsh from the environment in the test
test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1 shebang # We want to run with pwsh from the environment in the test
test/integration/targets/win_exec_wrapper/library/test_fail.ps1 pslint:PSCustomUseLiteralPath
test/integration/targets/win_exec_wrapper/tasks/main.yml no-smart-quotes # We are explicitly testing smart quote support for env vars
test/integration/targets/win_fetch/tasks/main.yml no-smart-quotes # We are explictly testing smart quotes in the file name to fetch

@ -37,6 +37,7 @@ from ansible.module_utils.common.text.converters import to_bytes
from ansible._internal._datatag._tags import TrustedAsTemplate
from ansible.playbook.play_context import PlayContext
from ansible.plugins.action import ActionBase
from ansible.plugins.shell import _ShellCommand
from ansible.vars.clean import clean_facts
from ansible.template import Templar
from ansible.plugins import loader
@ -293,7 +294,7 @@ class TestActionBase(unittest.TestCase):
# create a mock connection, so we don't actually try and connect to things
mock_connection = MagicMock()
mock_connection.transport = 'ssh'
mock_connection._shell.mkdtemp.return_value = 'mkdir command'
mock_connection._shell._mkdtemp2.return_value = _ShellCommand(command='mkdir command')
mock_connection._shell.join_path.side_effect = os.path.join
mock_connection._shell.get_option = get_shell_opt
mock_connection._shell.HOMES_RE = re.compile(r'(\'|\")?(~|\$HOME)(.*)')

Loading…
Cancel
Save