diff --git a/.gitignore b/.gitignore index 57019fd1ab6..6551222edd2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/changelogs/fragments/windows-app-control.yml b/changelogs/fragments/windows-app-control.yml new file mode 100644 index 00000000000..6f0d803b5f8 --- /dev/null +++ b/changelogs/fragments/windows-app-control.yml @@ -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. diff --git a/lib/ansible/executor/module_common.py b/lib/ansible/executor/module_common.py index 79832e8b929..a447f8c1c86 100644 --- a/lib/ansible/executor/module_common.py +++ b/lib/ansible/executor/module_common.py @@ -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: diff --git a/lib/ansible/executor/powershell/async_watchdog.ps1 b/lib/ansible/executor/powershell/async_watchdog.ps1 index 9eef1efa960..96439d1d347 100644 --- a/lib/ansible/executor/powershell/async_watchdog.ps1 +++ b/lib/ansible/executor/powershell/async_watchdog.ps1 @@ -40,7 +40,7 @@ param([ScriptBlock]$ScriptBlock, $Param) & $ScriptBlock.Ast.GetScriptBlock() @Param '@).AddParameters( @{ - ScriptBlock = $execInfo.ScriptBlock + ScriptBlock = $execInfo.ScriptInfo.ScriptBlock Param = $execInfo.Parameters }) diff --git a/lib/ansible/executor/powershell/async_wrapper.ps1 b/lib/ansible/executor/powershell/async_wrapper.ps1 index 912f5334b1f..daaa2cd333b 100644 --- a/lib/ansible/executor/powershell/async_wrapper.ps1 +++ b/lib/ansible/executor/powershell/async_wrapper.ps1 @@ -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)" diff --git a/lib/ansible/executor/powershell/become_wrapper.ps1 b/lib/ansible/executor/powershell/become_wrapper.ps1 index 3a911acc695..6d2c250e58b 100644 --- a/lib/ansible/executor/powershell/become_wrapper.ps1 +++ b/lib/ansible/executor/powershell/become_wrapper.ps1 @@ -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 = @( diff --git a/lib/ansible/executor/powershell/bootstrap_wrapper.ps1 b/lib/ansible/executor/powershell/bootstrap_wrapper.ps1 index a98f2aae290..fec00b2772e 100644 --- a/lib/ansible/executor/powershell/bootstrap_wrapper.ps1 +++ b/lib/ansible/executor/powershell/bootstrap_wrapper.ps1 @@ -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 + } +} diff --git a/lib/ansible/executor/powershell/exec_wrapper.ps1 b/lib/ansible/executor/powershell/exec_wrapper.ps1 index becbaae862c..17eb2181b4b 100644 --- a/lib/ansible/executor/powershell/exec_wrapper.ps1 +++ b/lib/ansible/executor/powershell/exec_wrapper.ps1 @@ -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 + } } } diff --git a/lib/ansible/executor/powershell/module_manifest.py b/lib/ansible/executor/powershell/module_manifest.py index 490fd3b6c2b..94bb8fd9d22 100644 --- a/lib/ansible/executor/powershell/module_manifest.py +++ b/lib/ansible/executor/powershell/module_manifest.py @@ -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 diff --git a/lib/ansible/executor/powershell/module_wrapper.ps1 b/lib/ansible/executor/powershell/module_wrapper.ps1 index 62393066c75..b1839c1aa65 100644 --- a/lib/ansible/executor/powershell/module_wrapper.ps1 +++ b/lib/ansible/executor/powershell/module_wrapper.ps1 @@ -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:} = [System.Management.Automation.Language.Parser]::ParseInput( $args[0], $args[1], [ref]$null, [ref]$null).GetScriptBlock() '@).AddArgument($scriptInfo.Script).AddArgument($Script).AddStatement() -$null = $ps.AddCommand('', $false).AddStatement() + $null = $ps.AddCommand('', $false).AddStatement() +} if ($Breakpoints) { $ps.Runspace.Debugger.SetBreakpoints($Breakpoints) diff --git a/lib/ansible/executor/powershell/powershell_expand_user.ps1 b/lib/ansible/executor/powershell/powershell_expand_user.ps1 new file mode 100644 index 00000000000..ad9b749240b --- /dev/null +++ b/lib/ansible/executor/powershell/powershell_expand_user.ps1 @@ -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 +} diff --git a/lib/ansible/executor/powershell/powershell_mkdtemp.ps1 b/lib/ansible/executor/powershell/powershell_mkdtemp.ps1 new file mode 100644 index 00000000000..14749f94ef1 --- /dev/null +++ b/lib/ansible/executor/powershell/powershell_mkdtemp.ps1 @@ -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 diff --git a/lib/ansible/plugins/action/__init__.py b/lib/ansible/plugins/action/__init__.py index deba249c39c..28a0dcd5af5 100644 --- a/lib/ansible/plugins/action/__init__.py +++ b/lib/ansible/plugins/action/__init__.py @@ -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] diff --git a/lib/ansible/plugins/connection/winrm.py b/lib/ansible/plugins/connection/winrm.py index ffa9b6279eb..6ee52040c33 100644 --- a/lib/ansible/plugins/connection/winrm.py +++ b/lib/ansible/plugins/connection/winrm.py @@ -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) diff --git a/lib/ansible/plugins/shell/__init__.py b/lib/ansible/plugins/shell/__init__.py index 884af0c5a1b..57e0e930b71 100644 --- a/lib/ansible/plugins/shell/__init__.py +++ b/lib/ansible/plugins/shell/__init__.py @@ -16,6 +16,7 @@ # along with Ansible. If not, see . 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) diff --git a/lib/ansible/plugins/shell/powershell.py b/lib/ansible/plugins/shell/powershell.py index 33285be7050..6ee43cb23d2 100644 --- a/lib/ansible/plugins/shell/powershell.py +++ b/lib/ansible/plugins/shell/powershell.py @@ -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 = """ diff --git a/test/integration/targets/win_app_control/aliases b/test/integration/targets/win_app_control/aliases new file mode 100644 index 00000000000..0ced80ce0af --- /dev/null +++ b/test/integration/targets/win_app_control/aliases @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpSigned.cs b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpSigned.cs new file mode 100644 index 00000000000..3fc10eab539 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpSigned.cs @@ -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; + } + } +} diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpUnsigned.cs b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpUnsigned.cs new file mode 100644 index 00000000000..6e6498ebfc2 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/CSharpUnsigned.cs @@ -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; + } + } +} diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshSigned.psm1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshSigned.psm1 new file mode 100644 index 00000000000..82ab1161c2b --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshSigned.psm1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshUnsigned.psm1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshUnsigned.psm1 new file mode 100644 index 00000000000..84ec5d67651 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/module_utils/PwshUnsigned.psm1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed.ps1 new file mode 100644 index 00000000000..52d15480836 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed_not_trusted.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed_not_trusted.ps1 new file mode 100644 index 00000000000..2929c998baa --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/inline_signed_not_trusted.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/scope.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/scope.ps1 new file mode 100644 index 00000000000..01ce717b7fe --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/scope.ps1 @@ -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() diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed.ps1 new file mode 100644 index 00000000000..5f3581c56fe --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed.ps1 @@ -0,0 +1,10 @@ +#!powershell + +#AnsibleRequires -Wrapper + +@{ + test = 'signed' + language_mode = $ExecutionContext.SessionState.LanguageMode.ToString() + whoami = [Environment]::UserName + ünicode = $complex_args.input +} | ConvertTo-Json diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed_module_util.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed_module_util.ps1 new file mode 100644 index 00000000000..f7b41ace4ca --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/signed_module_util.ps1 @@ -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() diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/skipped.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/skipped.ps1 new file mode 100644 index 00000000000..0ff224a07ac --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/skipped.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_csharp_util.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_csharp_util.ps1 new file mode 100644 index 00000000000..a62c1397d94 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_csharp_util.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_module_with_util.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_module_with_util.ps1 new file mode 100644 index 00000000000..75df2b3bc68 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_module_with_util.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_pwsh_util.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_pwsh_util.ps1 new file mode 100644 index 00000000000..2287a362351 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsigned_pwsh_util.ps1 @@ -0,0 +1,9 @@ +#!powershell + +#AnsibleRequires -PowerShell ..module_utils.PwshUnsigned + +@{ + changed = $false + language_mode = $ExecutionContext.SessionState.LanguageMode.ToString() + res = Test-PwshUnsigned +} | ConvertTo-Json diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsupported.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsupported.ps1 new file mode 100644 index 00000000000..3f055a7c4c4 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/plugins/modules/unsupported.ps1 @@ -0,0 +1,9 @@ +#!powershell + +#AnsibleRequires -Wrapper + +@{ + test = 'unsupported' + language_mode = $ExecutionContext.SessionState.LanguageMode.ToString() + whoami = [Environment]::UserName +} | ConvertTo-Json diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/signed.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/signed.ps1 new file mode 100644 index 00000000000..6ddd60f73fd --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/signed.ps1 @@ -0,0 +1,5 @@ +@{ + language_mode = $ExecutionContext.SessionState.LanguageMode.ToString() + whoami = [Environment]::UserName + ünicode = $args[0] +} | ConvertTo-Json diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/unsigned.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/unsigned.ps1 new file mode 100644 index 00000000000..6ddd60f73fd --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/files/unsigned.ps1 @@ -0,0 +1,5 @@ +@{ + language_mode = $ExecutionContext.SessionState.LanguageMode.ToString() + whoami = [Environment]::UserName + ünicode = $args[0] +} | ConvertTo-Json diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/tasks/main.yml b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/tasks/main.yml new file mode 100644 index 00000000000..0fc6f87a9bd --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/col/roles/app_control_script/tasks/main.yml @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/invalid_manifest/meta/.keep b/test/integration/targets/win_app_control/ansible_collections/ns/invalid_manifest/meta/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/invalid_manifest/plugins/modules/module.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/invalid_manifest/plugins/modules/module.ps1 new file mode 100644 index 00000000000..1780085513a --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/invalid_manifest/plugins/modules/module.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/ansible_collections/ns/module_util_ref/plugins/modules/module.ps1 b/test/integration/targets/win_app_control/ansible_collections/ns/module_util_ref/plugins/modules/module.ps1 new file mode 100644 index 00000000000..5db15c39991 --- /dev/null +++ b/test/integration/targets/win_app_control/ansible_collections/ns/module_util_ref/plugins/modules/module.ps1 @@ -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() diff --git a/test/integration/targets/win_app_control/create_manifest.yml b/test/integration/targets/win_app_control/create_manifest.yml new file mode 100644 index 00000000000..181b355c48c --- /dev/null +++ b/test/integration/targets/win_app_control/create_manifest.yml @@ -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 diff --git a/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 b/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 new file mode 100644 index 00000000000..dfa28393597 --- /dev/null +++ b/test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1 b/test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1 new file mode 100755 index 00000000000..4b45c1eed03 --- /dev/null +++ b/test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1 @@ -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 diff --git a/test/integration/targets/win_app_control/main.yml b/test/integration/targets/win_app_control/main.yml new file mode 100644 index 00000000000..4856b692c79 --- /dev/null +++ b/test/integration/targets/win_app_control/main.yml @@ -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 diff --git a/test/integration/targets/win_app_control/runme.sh b/test/integration/targets/win_app_control/runme.sh new file mode 100755 index 00000000000..80f9ca13c92 --- /dev/null +++ b/test/integration/targets/win_app_control/runme.sh @@ -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}" \ + "${@}" diff --git a/test/integration/targets/win_app_control/setup.yml b/test/integration/targets/win_app_control/setup.yml new file mode 100644 index 00000000000..468b0a5a06b --- /dev/null +++ b/test/integration/targets/win_app_control/setup.yml @@ -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 diff --git a/test/integration/targets/win_app_control/templates/manifest_invalid_version.psd1 b/test/integration/targets/win_app_control/templates/manifest_invalid_version.psd1 new file mode 100644 index 00000000000..5be80b457c0 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_invalid_version.psd1 @@ -0,0 +1,9 @@ +@{ + Version = 2 + HashList = @( + @{ + Hash = '{{ module_hash }}' + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_no_hashtable.psd1 b/test/integration/targets/win_app_control/templates/manifest_no_hashtable.psd1 new file mode 100644 index 00000000000..5eda410c877 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_no_hashtable.psd1 @@ -0,0 +1 @@ +'failure' diff --git a/test/integration/targets/win_app_control/templates/manifest_no_version.psd1 b/test/integration/targets/win_app_control/templates/manifest_no_version.psd1 new file mode 100644 index 00000000000..b0f5bce049a --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_no_version.psd1 @@ -0,0 +1,8 @@ +@{ + HashList = @( + @{ + Hash = '{{ module_hash }}' + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_invalid_hash_subkey.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_invalid_hash_subkey.psd1 new file mode 100644 index 00000000000..bbcfc39899b --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_invalid_hash_subkey.psd1 @@ -0,0 +1,9 @@ +@{ + Version = 1 + HashList = @( + @{ + Hash = '{{ module_hash }} - extra' + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_invalid_mode_subkey.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_invalid_mode_subkey.psd1 new file mode 100644 index 00000000000..0c894228e5b --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_invalid_mode_subkey.psd1 @@ -0,0 +1,9 @@ +@{ + Version = 1 + HashList = @( + @{ + Hash = '{{ module_hash }}' + Mode = 'Other' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_no_hash_subkey.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_no_hash_subkey.psd1 new file mode 100644 index 00000000000..431b9045d53 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_no_hash_subkey.psd1 @@ -0,0 +1,8 @@ +@{ + Version = 1 + HashList = @( + @{ + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_no_hashlist.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_no_hashlist.psd1 new file mode 100644 index 00000000000..60aa96adacc --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_no_hashlist.psd1 @@ -0,0 +1,3 @@ +@{ + Version = 1 +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_no_mode_subkey.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_no_mode_subkey.psd1 new file mode 100644 index 00000000000..fe84698a930 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_no_mode_subkey.psd1 @@ -0,0 +1,8 @@ +@{ + Version = 1 + HashList = @( + @{ + Hash = '{{ module_hash }}' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_ok.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_ok.psd1 new file mode 100644 index 00000000000..6df63394a79 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_ok.psd1 @@ -0,0 +1,9 @@ +@{ + Version = 1 + HashList = @( + @{ + Hash = '{{ module_hash }}' + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/templates/manifest_v1_unsafe_expression.psd1 b/test/integration/targets/win_app_control/templates/manifest_v1_unsafe_expression.psd1 new file mode 100644 index 00000000000..0722ae827c7 --- /dev/null +++ b/test/integration/targets/win_app_control/templates/manifest_v1_unsafe_expression.psd1 @@ -0,0 +1,9 @@ +@{ + Version = 1 + HashList = @( + @{ + Hash = "$env:TEST_HASH" + Mode = 'Trusted' + } + ) +} diff --git a/test/integration/targets/win_app_control/test_enabled.yml b/test/integration/targets/win_app_control/test_enabled.yml new file mode 100644 index 00000000000..78620750689 --- /dev/null +++ b/test/integration/targets/win_app_control/test_enabled.yml @@ -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'}" diff --git a/test/integration/targets/win_app_control/test_manifest.yml b/test/integration/targets/win_app_control/test_manifest.yml new file mode 100644 index 00000000000..1b3355d4d27 --- /dev/null +++ b/test/integration/targets/win_app_control/test_manifest.yml @@ -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") diff --git a/test/integration/targets/win_app_control/test_not_enabled.yml b/test/integration/targets/win_app_control/test_not_enabled.yml new file mode 100644 index 00000000000..5bcf422c2da --- /dev/null +++ b/test/integration/targets/win_app_control/test_not_enabled.yml @@ -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'}" diff --git a/test/integration/targets/win_exec_wrapper/library/test_no_utils.ps1 b/test/integration/targets/win_exec_wrapper/library/test_no_utils.ps1 new file mode 100644 index 00000000000..8579943b98e --- /dev/null +++ b/test/integration/targets/win_exec_wrapper/library/test_no_utils.ps1 @@ -0,0 +1,8 @@ +#!powershell + +#AnsibleRequires -Wrapper + +@{ + changed = $false + complex_args = $complex_args +} | ConvertTo-Json -Depth 99 diff --git a/test/integration/targets/win_exec_wrapper/tasks/main.yml b/test/integration/targets/win_exec_wrapper/tasks/main.yml index 5ce249b6c4d..8b9b4b9dcbc 100644 --- a/test/integration/targets/win_exec_wrapper/tasks/main.yml +++ b/test/integration/targets/win_exec_wrapper/tasks/main.yml @@ -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'}" diff --git a/test/integration/targets/win_script/files/test_script_unicode.ps1 b/test/integration/targets/win_script/files/test_script_unicode.ps1 new file mode 100644 index 00000000000..c1fe71577ac --- /dev/null +++ b/test/integration/targets/win_script/files/test_script_unicode.ps1 @@ -0,0 +1 @@ +'ünicode' diff --git a/test/integration/targets/win_script/tasks/main.yml b/test/integration/targets/win_script/tasks/main.yml index 8374897e107..509d55dc4ce 100644 --- a/test/integration/targets/win_script/tasks/main.yml +++ b/test/integration/targets/win_script/tasks/main.yml @@ -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' diff --git a/test/sanity/ignore.txt b/test/sanity/ignore.txt index 64937d1cc3c..fad9e780eb4 100644 --- a/test/sanity/ignore.txt +++ b/test/sanity/ignore.txt @@ -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 diff --git a/test/units/plugins/action/test_action.py b/test/units/plugins/action/test_action.py index 1eeb2893582..f99332d334b 100644 --- a/test/units/plugins/action/test_action.py +++ b/test/units/plugins/action/test_action.py @@ -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)(.*)')