mirror of
https://github.com/zebrajr/ansible.git
synced 2025-12-06 12:19:53 +01:00
Add support for Windows App Control/WDAC (#84898)
* Add support for Windows App Control/WDAC Adds preview support for Windows App Control, formerly known as WDAC. This is a tech preview feature and is designed to test out improvements needed in future versions of Ansible. * Use psd1 and parse it through the Ast to avoid any unexpected execution results * Add tests for various manifest permutations * Ignore test shebang failure * Apply suggestions from code review Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com> * Use more flexible test expectations * Add type annotations for shell functions --------- Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com>
This commit is contained in:
parent
e82be177cd
commit
75f7b2267d
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -97,6 +97,9 @@ Vagrantfile
|
||||||
# vendored lib dir
|
# vendored lib dir
|
||||||
lib/ansible/_vendor/*
|
lib/ansible/_vendor/*
|
||||||
!lib/ansible/_vendor/__init__.py
|
!lib/ansible/_vendor/__init__.py
|
||||||
|
# PowerShell signed hashlist
|
||||||
|
lib/ansible/config/powershell_signatures.psd1
|
||||||
|
*.authenticode
|
||||||
# test stuff
|
# test stuff
|
||||||
/test/integration/cloud-config-*.*
|
/test/integration/cloud-config-*.*
|
||||||
!/test/integration/cloud-config-*.*.template
|
!/test/integration/cloud-config-*.*.template
|
||||||
|
|
|
||||||
9
changelogs/fragments/windows-app-control.yml
Normal file
9
changelogs/fragments/windows-app-control.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
minor_changes:
|
||||||
|
- >-
|
||||||
|
windows - Added support for ``#AnsibleRequires -Wrapper`` to request a PowerShell module be run through the
|
||||||
|
execution wrapper scripts without any module utils specified.
|
||||||
|
- >-
|
||||||
|
windows - Added support for running signed modules and scripts with a Windows host protected by Windows App
|
||||||
|
Control/WDAC. This is a tech preview and the interface may be subject to change.
|
||||||
|
- >-
|
||||||
|
windows - Script modules will preserve UTF-8 encoding when executing the script.
|
||||||
|
|
@ -991,10 +991,8 @@ def _find_module_utils(
|
||||||
module_substyle = 'powershell'
|
module_substyle = 'powershell'
|
||||||
b_module_data = b_module_data.replace(REPLACER_WINDOWS, b'#AnsibleRequires -PowerShell Ansible.ModuleUtils.Legacy')
|
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) \
|
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'#Requires -Version', b_module_data, re.IGNORECASE) \
|
||||||
or re.search(b'#AnsibleRequires -OSVersion', b_module_data, re.IGNORECASE) \
|
or re.search(b'#AnsibleRequires -(OSVersion|PowerShell|CSharpUtil|Wrapper)', 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):
|
|
||||||
module_style = 'new'
|
module_style = 'new'
|
||||||
module_substyle = 'powershell'
|
module_substyle = 'powershell'
|
||||||
elif REPLACER_JSONARGS in b_module_data:
|
elif REPLACER_JSONARGS in b_module_data:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ param([ScriptBlock]$ScriptBlock, $Param)
|
||||||
& $ScriptBlock.Ast.GetScriptBlock() @Param
|
& $ScriptBlock.Ast.GetScriptBlock() @Param
|
||||||
'@).AddParameters(
|
'@).AddParameters(
|
||||||
@{
|
@{
|
||||||
ScriptBlock = $execInfo.ScriptBlock
|
ScriptBlock = $execInfo.ScriptInfo.ScriptBlock
|
||||||
Param = $execInfo.Parameters
|
Param = $execInfo.Parameters
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ try {
|
||||||
}
|
}
|
||||||
$execWrapper = @{
|
$execWrapper = @{
|
||||||
name = 'exec_wrapper-async.ps1'
|
name = 'exec_wrapper-async.ps1'
|
||||||
script = $execAction.Script
|
script = $execAction.ScriptInfo.Script
|
||||||
params = $execAction.Parameters
|
params = $execAction.Parameters
|
||||||
} | ConvertTo-Json -Compress -Depth 99
|
} | ConvertTo-Json -Compress -Depth 99
|
||||||
$asyncInput = "$execWrapper`n`0`0`0`0`n$($execAction.InputData)"
|
$asyncInput = "$execWrapper`n`0`0`0`0`n$($execAction.InputData)"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ using namespace System.Collections
|
||||||
using namespace System.Diagnostics
|
using namespace System.Diagnostics
|
||||||
using namespace System.IO
|
using namespace System.IO
|
||||||
using namespace System.Management.Automation
|
using namespace System.Management.Automation
|
||||||
|
using namespace System.Management.Automation.Security
|
||||||
using namespace System.Net
|
using namespace System.Net
|
||||||
using namespace System.Text
|
using namespace System.Text
|
||||||
|
|
||||||
|
|
@ -53,7 +54,7 @@ $executablePath = Join-Path -Path $PSHome -ChildPath $executable
|
||||||
$actionInfo = Get-AnsibleExecWrapper -EncodeInputOutput
|
$actionInfo = Get-AnsibleExecWrapper -EncodeInputOutput
|
||||||
$bootstrapManifest = ConvertTo-Json -InputObject @{
|
$bootstrapManifest = ConvertTo-Json -InputObject @{
|
||||||
n = "exec_wrapper-become-$([Guid]::NewGuid()).ps1"
|
n = "exec_wrapper-become-$([Guid]::NewGuid()).ps1"
|
||||||
s = $actionInfo.Script
|
s = $actionInfo.ScriptInfo.Script
|
||||||
p = $actionInfo.Parameters
|
p = $actionInfo.Parameters
|
||||||
} -Depth 99 -Compress
|
} -Depth 99 -Compress
|
||||||
|
|
||||||
|
|
@ -68,10 +69,27 @@ $m=foreach($i in $input){
|
||||||
$m=$m|ConvertFrom-Json
|
$m=$m|ConvertFrom-Json
|
||||||
$p=@{}
|
$p=@{}
|
||||||
foreach($o in $m.p.PSObject.Properties){$p[$o.Name]=$o.Value}
|
foreach($o in $m.p.PSObject.Properties){$p[$o.Name]=$o.Value}
|
||||||
$c=[System.Management.Automation.Language.Parser]::ParseInput($m.s,$m.n,[ref]$null,[ref]$null).GetScriptBlock()
|
|
||||||
$input | & $c @p
|
|
||||||
'@
|
'@
|
||||||
|
|
||||||
|
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
|
||||||
|
'@
|
||||||
|
}
|
||||||
|
|
||||||
# Strip out any leading or trailing whitespace and remove empty lines.
|
# Strip out any leading or trailing whitespace and remove empty lines.
|
||||||
$command = @(
|
$command = @(
|
||||||
($command -split "\r?\n") |
|
($command -split "\r?\n") |
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,32 @@ foreach ($obj in $code.params.PSObject.Properties) {
|
||||||
$splat[$obj.Name] = $obj.Value
|
$splat[$obj.Name] = $obj.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
$cmd = [System.Management.Automation.Language.Parser]::ParseInput(
|
$filePath = $null
|
||||||
$code.script,
|
try {
|
||||||
"$($code.name).ps1", # Name is used in stack traces.
|
$cmd = if ($ExecutionContext.SessionState.LanguageMode -eq 'FullLanguage') {
|
||||||
[ref]$null,
|
# In FLM we can just invoke the code as a scriptblock without touching the
|
||||||
[ref]$null).GetScriptBlock()
|
# 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
|
||||||
|
|
||||||
$input | & $cmd @splat
|
$filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
$input | & $cmd @splat
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($filePath -and (Test-Path -LiteralPath $filePath)) {
|
||||||
|
Remove-Item -LiteralPath $filePath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ using namespace System.Linq
|
||||||
using namespace System.Management.Automation
|
using namespace System.Management.Automation
|
||||||
using namespace System.Management.Automation.Language
|
using namespace System.Management.Automation.Language
|
||||||
using namespace System.Management.Automation.Security
|
using namespace System.Management.Automation.Security
|
||||||
|
using namespace System.Reflection
|
||||||
using namespace System.Security.Cryptography
|
using namespace System.Security.Cryptography
|
||||||
using namespace System.Text
|
using namespace System.Text
|
||||||
|
|
||||||
|
|
@ -53,6 +54,10 @@ begin {
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
$ProgressPreference = "SilentlyContinue"
|
$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
|
# Try and set the console encoding to UTF-8 allowing Ansible to read the
|
||||||
# output of the wrapper as UTF-8 bytes.
|
# output of the wrapper as UTF-8 bytes.
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,6 +94,9 @@ begin {
|
||||||
}
|
}
|
||||||
|
|
||||||
# $Script:AnsibleManifest = @{} # Defined in process/end.
|
# $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:AnsibleWrapperWarnings = [List[string]]::new()
|
||||||
$Script:AnsibleTempPath = @(
|
$Script:AnsibleTempPath = @(
|
||||||
# Wrapper defined tmpdir
|
# Wrapper defined tmpdir
|
||||||
|
|
@ -110,6 +118,8 @@ begin {
|
||||||
$false
|
$false
|
||||||
}
|
}
|
||||||
} | Select-Object -First 1
|
} | Select-Object -First 1
|
||||||
|
$Script:AnsibleTempScripts = [List[string]]::new()
|
||||||
|
$Script:AnsibleClrFacadeSet = $false
|
||||||
|
|
||||||
Function Convert-JsonObject {
|
Function Convert-JsonObject {
|
||||||
param(
|
param(
|
||||||
|
|
@ -147,7 +157,11 @@ begin {
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[switch]
|
[switch]
|
||||||
$IncludeScriptBlock
|
$IncludeScriptBlock,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[switch]
|
||||||
|
$SkipHashCheck
|
||||||
)
|
)
|
||||||
|
|
||||||
if (-not $Script:AnsibleManifest.scripts.Contains($Name)) {
|
if (-not $Script:AnsibleManifest.scripts.Contains($Name)) {
|
||||||
|
|
@ -172,11 +186,93 @@ begin {
|
||||||
[ref]$null).GetScriptBlock()
|
[ref]$null).GetScriptBlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
[PSCustomObject]@{
|
$outputValue = [PSCustomObject]@{
|
||||||
Name = $Name
|
Name = $Name
|
||||||
Script = $scriptContents
|
Script = $scriptContents
|
||||||
Path = $scriptInfo.path
|
Path = $scriptInfo.path
|
||||||
ScriptBlock = $sbk
|
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
|
$IncludeScriptBlock
|
||||||
)
|
)
|
||||||
|
|
||||||
$sbk = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
|
$scriptInfo = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
|
||||||
$params = @{
|
$params = @{
|
||||||
# TempPath may contain env vars that change based on the runtime
|
# TempPath may contain env vars that change based on the runtime
|
||||||
# environment. Ensure we use that and not the $script:AnsibleTempPath
|
# environment. Ensure we use that and not the $script:AnsibleTempPath
|
||||||
|
|
@ -244,8 +340,7 @@ begin {
|
||||||
}
|
}
|
||||||
|
|
||||||
[PSCustomObject]@{
|
[PSCustomObject]@{
|
||||||
Script = $sbk.Script
|
ScriptInfo = $scriptInfo
|
||||||
ScriptBlock = $sbk.ScriptBlock
|
|
||||||
Parameters = $params
|
Parameters = $params
|
||||||
InputData = $inputData
|
InputData = $inputData
|
||||||
}
|
}
|
||||||
|
|
@ -279,11 +374,16 @@ begin {
|
||||||
|
|
||||||
$isBasicUtil = $false
|
$isBasicUtil = $false
|
||||||
$csharpModules = foreach ($moduleName in $Name) {
|
$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') {
|
if ($moduleName -eq 'Ansible.Basic.cs') {
|
||||||
$isBasicUtil = $true
|
$isBasicUtil = $true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scriptInfo.Script
|
||||||
}
|
}
|
||||||
|
|
||||||
$fakeModule = [PSCustomObject]@{
|
$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 {
|
Function Write-AnsibleErrorJson {
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param (
|
param (
|
||||||
|
|
@ -414,6 +620,10 @@ begin {
|
||||||
$Script:AnsibleManifest = $Manifest
|
$Script:AnsibleManifest = $Manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($Script:AnsibleShouldConstrain) {
|
||||||
|
$Script:AnsibleManifest.signed_hashlist | Import-SignedHashList
|
||||||
|
}
|
||||||
|
|
||||||
$actionInfo = Get-NextAnsibleAction
|
$actionInfo = Get-NextAnsibleAction
|
||||||
$actionParams = $actionInfo.Parameters
|
$actionParams = $actionInfo.Parameters
|
||||||
|
|
||||||
|
|
@ -500,5 +710,8 @@ end {
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
$actionPipeline.Dispose()
|
$actionPipeline.Dispose()
|
||||||
|
if ($Script:AnsibleTempScripts) {
|
||||||
|
Remove-Item -LiteralPath $Script:AnsibleTempScripts -Force -ErrorAction Ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ from ansible.plugins.loader import ps_module_utils_loader
|
||||||
class _ExecManifest:
|
class _ExecManifest:
|
||||||
scripts: dict[str, _ScriptInfo] = dataclasses.field(default_factory=dict)
|
scripts: dict[str, _ScriptInfo] = dataclasses.field(default_factory=dict)
|
||||||
actions: list[_ManifestAction] = dataclasses.field(default_factory=list)
|
actions: list[_ManifestAction] = dataclasses.field(default_factory=list)
|
||||||
|
signed_hashlist: list[str] = dataclasses.field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True, kw_only=True)
|
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||||
|
|
@ -54,6 +55,11 @@ class PSModuleDepFinder(object):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# This is also used by validate-modules to get a module's required utils in base and a collection.
|
# 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.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]] = {}
|
self._util_deps: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
|
@ -119,6 +125,15 @@ class PSModuleDepFinder(object):
|
||||||
lines = module_data.split(b'\n')
|
lines = module_data.split(b'\n')
|
||||||
module_utils: set[tuple[str, str, bool]] = set()
|
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:
|
if powershell:
|
||||||
checks = [
|
checks = [
|
||||||
# PS module contains '#Requires -Module Ansible.ModuleUtils.*'
|
# 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_wrapper = _get_powershell_script("bootstrap_wrapper.ps1")
|
||||||
bootstrap_input = _get_bootstrap_input(exec_manifest)
|
bootstrap_input = _get_bootstrap_input(exec_manifest)
|
||||||
if has_input:
|
if has_input:
|
||||||
|
|
@ -339,6 +358,14 @@ def _get_powershell_script(
|
||||||
if code is None:
|
if code is None:
|
||||||
raise AnsibleFileNotFound(f"Could not find powershell script '{package_name}.{name}'")
|
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
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -501,6 +528,7 @@ def _create_powershell_wrapper(
|
||||||
exec_manifest = _ExecManifest(
|
exec_manifest = _ExecManifest(
|
||||||
scripts=finder.scripts,
|
scripts=finder.scripts,
|
||||||
actions=actions,
|
actions=actions,
|
||||||
|
signed_hashlist=list(finder.signed_hashlist),
|
||||||
)
|
)
|
||||||
|
|
||||||
return _get_bootstrap_input(
|
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)
|
encoder = get_module_encoder(profile, Direction.CONTROLLER_TO_MODULE)
|
||||||
|
|
||||||
return json.loads(json.dumps(module_args, cls=encoder))
|
return json.loads(json.dumps(module_args, cls=encoder))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_powershell_signed_hashlist(
|
||||||
|
collection: str | None = None,
|
||||||
|
) -> _ScriptInfo | None:
|
||||||
|
"""Gets the signed hashlist script stored in either the Ansible package or for
|
||||||
|
the collection specified.
|
||||||
|
|
||||||
|
:param collection: The collection namespace to get the signed hashlist for or None for the builtin.
|
||||||
|
:return: The _ScriptInfo payload of the signed hashlist script if found, None if not.
|
||||||
|
"""
|
||||||
|
resource = 'ansible.config' if collection is None else f"{collection}.meta"
|
||||||
|
signature_file = 'powershell_signatures.psd1'
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig_data = pkgutil.get_data(resource, signature_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sig_data = None
|
||||||
|
|
||||||
|
if sig_data:
|
||||||
|
resource_path = f"{resource}.{signature_file}"
|
||||||
|
return _ScriptInfo(content=sig_data, path=resource_path)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ $ps = [PowerShell]::Create()
|
||||||
if ($ForModule) {
|
if ($ForModule) {
|
||||||
$ps.Runspace.SessionStateProxy.SetVariable("ErrorActionPreference", "Stop")
|
$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) {
|
foreach ($variable in $Variables) {
|
||||||
$null = $ps.AddCommand("Set-Variable").AddParameters($variable).AddStatement()
|
$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()
|
$null = $ps.AddScript('Function Write-Host($msg) { Write-Output -InputObject $msg }').AddStatement()
|
||||||
|
|
||||||
$scriptInfo = Get-AnsibleScript -Name $Script
|
$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) {
|
# If the module is marked as needing to be constrained then we set the
|
||||||
foreach ($utilName in $PowerShellModules) {
|
# language mode to ConstrainedLanguage so that when parsed inside the
|
||||||
$utilInfo = Get-AnsibleScript -Name $utilName
|
# 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)
|
param ($Name, $Script)
|
||||||
|
|
||||||
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($Name)
|
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($Name)
|
||||||
|
|
@ -130,32 +153,33 @@ $sbk = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||||
New-Module -Name $moduleName -ScriptBlock $sbk |
|
New-Module -Name $moduleName -ScriptBlock $sbk |
|
||||||
Import-Module -WarningAction SilentlyContinue -Scope Global
|
Import-Module -WarningAction SilentlyContinue -Scope Global
|
||||||
'@, $true)
|
'@, $true)
|
||||||
$null = $ps.AddParameters(
|
$null = $ps.AddParameters(
|
||||||
@{
|
@{
|
||||||
Name = $utilName
|
Name = $utilName
|
||||||
Script = $utilInfo.Script
|
Script = $utilInfo.Script
|
||||||
}
|
}
|
||||||
).AddStatement()
|
).AddStatement()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ($CSharpModules) {
|
if ($CSharpModules) {
|
||||||
# C# utils are process wide so just load them here.
|
# C# utils are process wide so just load them here.
|
||||||
Import-CSharpUtil -Name $CSharpModules
|
Import-CSharpUtil -Name $CSharpModules
|
||||||
}
|
}
|
||||||
|
|
||||||
# We invoke it through a command with useLocalScope $false to
|
# We invoke it through a command with useLocalScope $false to
|
||||||
# ensure the code runs with it's own $script: scope. It also
|
# ensure the code runs with it's own $script: scope. It also
|
||||||
# cleans up the StackTrace on errors by not showing the stub
|
# cleans up the StackTrace on errors by not showing the stub
|
||||||
# execution line and starts immediately at the module "cmd".
|
# execution line and starts immediately at the module "cmd".
|
||||||
$null = $ps.AddScript(@'
|
$null = $ps.AddScript(@'
|
||||||
${function:<AnsibleModule>} = [System.Management.Automation.Language.Parser]::ParseInput(
|
${function:<AnsibleModule>} = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||||
$args[0],
|
$args[0],
|
||||||
$args[1],
|
$args[1],
|
||||||
[ref]$null,
|
[ref]$null,
|
||||||
[ref]$null).GetScriptBlock()
|
[ref]$null).GetScriptBlock()
|
||||||
'@).AddArgument($scriptInfo.Script).AddArgument($Script).AddStatement()
|
'@).AddArgument($scriptInfo.Script).AddArgument($Script).AddStatement()
|
||||||
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
|
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
|
||||||
|
}
|
||||||
|
|
||||||
if ($Breakpoints) {
|
if ($Breakpoints) {
|
||||||
$ps.Runspace.Debugger.SetBreakpoints($Breakpoints)
|
$ps.Runspace.Debugger.SetBreakpoints($Breakpoints)
|
||||||
|
|
|
||||||
20
lib/ansible/executor/powershell/powershell_expand_user.ps1
Normal file
20
lib/ansible/executor/powershell/powershell_expand_user.ps1
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
17
lib/ansible/executor/powershell/powershell_mkdtemp.ps1
Normal file
17
lib/ansible/executor/powershell/powershell_mkdtemp.ps1
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# (c) 2025 Ansible Project
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$Directory,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$path = [Environment]::ExpandEnvironmentVariables($Directory)
|
||||||
|
$tmp = New-Item -Path $path -Name $Name -ItemType Directory
|
||||||
|
$tmp.FullName
|
||||||
|
|
@ -474,8 +474,8 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
|
||||||
|
|
||||||
become_unprivileged = self._is_become_unprivileged()
|
become_unprivileged = self._is_become_unprivileged()
|
||||||
basefile = self._connection._shell._generate_temp_dir_name()
|
basefile = self._connection._shell._generate_temp_dir_name()
|
||||||
cmd = self._connection._shell.mkdtemp(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
|
cmd = self._connection._shell._mkdtemp2(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
|
||||||
result = self._low_level_execute_command(cmd, sudoable=False)
|
result = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
|
||||||
|
|
||||||
# error handling on this seems a little aggressive?
|
# error handling on this seems a little aggressive?
|
||||||
if result['rc'] != 0:
|
if result['rc'] != 0:
|
||||||
|
|
@ -906,8 +906,8 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
|
||||||
expand_path = '~%s' % (self._get_remote_user() or '')
|
expand_path = '~%s' % (self._get_remote_user() or '')
|
||||||
|
|
||||||
# use shell to construct appropriate command and execute
|
# use shell to construct appropriate command and execute
|
||||||
cmd = self._connection._shell.expand_user(expand_path)
|
cmd = self._connection._shell._expand_user2(expand_path)
|
||||||
data = self._low_level_execute_command(cmd, sudoable=False)
|
data = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
initial_fragment = data['stdout'].strip().splitlines()[-1]
|
initial_fragment = data['stdout'].strip().splitlines()[-1]
|
||||||
|
|
|
||||||
|
|
@ -723,8 +723,11 @@ class Connection(ConnectionBase):
|
||||||
super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)
|
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)
|
encoded_prefix = self._shell._encode_script('', as_list=False, strict_mode=False, preserve_rc=False)
|
||||||
if cmd.startswith(encoded_prefix):
|
if cmd.startswith(encoded_prefix) or cmd.startswith("type "):
|
||||||
# Avoid double encoding the script
|
# 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(" ")
|
cmd_parts = cmd.split(" ")
|
||||||
else:
|
else:
|
||||||
cmd_parts = self._shell._encode_script(cmd, as_list=True, strict_mode=False, preserve_rc=False)
|
cmd_parts = self._shell._encode_script(cmd, as_list=True, strict_mode=False, preserve_rc=False)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
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]*$')
|
_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):
|
class ShellBase(AnsiblePlugin):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
||||||
|
|
@ -121,7 +129,13 @@ class ShellBase(AnsiblePlugin):
|
||||||
cmd = ['test', '-e', self.quote(path)]
|
cmd = ['test', '-e', self.quote(path)]
|
||||||
return ' '.join(cmd)
|
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:
|
if not basefile:
|
||||||
basefile = self.__class__._generate_temp_dir_name()
|
basefile = self.__class__._generate_temp_dir_name()
|
||||||
|
|
||||||
|
|
@ -163,7 +177,31 @@ class ShellBase(AnsiblePlugin):
|
||||||
|
|
||||||
return cmd
|
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
|
""" Return a command to expand tildes in a path
|
||||||
|
|
||||||
It can be either "~" or "~username". We just ignore $HOME
|
It can be either "~" or "~username". We just ignore $HOME
|
||||||
|
|
@ -184,6 +222,22 @@ class ShellBase(AnsiblePlugin):
|
||||||
|
|
||||||
return 'echo %s' % user_home_path
|
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):
|
def pwd(self):
|
||||||
"""Return the working directory after connecting"""
|
"""Return the working directory after connecting"""
|
||||||
return 'echo %spwd%s' % (self._SHELL_SUB_LEFT, self._SHELL_SUB_RIGHT)
|
return 'echo %spwd%s' % (self._SHELL_SUB_LEFT, self._SHELL_SUB_RIGHT)
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ import shlex
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
import ntpath
|
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.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
|
# 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
|
# 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:
|
else:
|
||||||
return self._encode_script("""Remove-Item '%s' -Force;""" % path)
|
return self._encode_script("""Remove-Item '%s' -Force;""" % path)
|
||||||
|
|
||||||
def mkdtemp(self, basefile=None, system=False, mode=None, tmpdir=None):
|
def mkdtemp(
|
||||||
# Windows does not have an equivalent for the system temp files, so
|
self,
|
||||||
# the param is ignored
|
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:
|
if not basefile:
|
||||||
basefile = self.__class__._generate_temp_dir_name()
|
basefile = self.__class__._generate_temp_dir_name()
|
||||||
basefile = self._escape(self._unquote(basefile))
|
basefile = self._escape(self._unquote(basefile))
|
||||||
|
|
@ -241,10 +247,38 @@ class ShellModule(ShellBase):
|
||||||
"""
|
"""
|
||||||
return self._encode_script(script.strip())
|
return self._encode_script(script.strip())
|
||||||
|
|
||||||
def expand_user(self, user_home_path, username=''):
|
def _mkdtemp2(
|
||||||
# PowerShell only supports "~" (not "~username"). Resolve-Path ~ does
|
self,
|
||||||
# not seem to work remotely, though by default we are always starting
|
basefile: str | None = None,
|
||||||
# in the user's home directory.
|
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)
|
user_home_path = self._unquote(user_home_path)
|
||||||
if user_home_path == '~':
|
if user_home_path == '~':
|
||||||
script = 'Write-Output (Get-Location).Path'
|
script = 'Write-Output (Get-Location).Path'
|
||||||
|
|
@ -254,6 +288,21 @@ class ShellModule(ShellBase):
|
||||||
script = "Write-Output '%s'" % self._escape(user_home_path)
|
script = "Write-Output '%s'" % self._escape(user_home_path)
|
||||||
return self._encode_script(f"{self._CONSOLE_ENCODING}; {script}")
|
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):
|
def exists(self, path):
|
||||||
path = self._escape(self._unquote(path))
|
path = self._escape(self._unquote(path))
|
||||||
script = """
|
script = """
|
||||||
|
|
|
||||||
5
test/integration/targets/win_app_control/aliases
Normal file
5
test/integration/targets/win_app_control/aliases
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
shippable/windows/group1
|
||||||
|
shippable/windows/smoketest
|
||||||
|
needs/target/collection
|
||||||
|
needs/target/setup_remote_tmp_dir
|
||||||
|
destructive # modifies files in the ansible installation dir
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ansible_collections.ns.col.plugins.module_utils.CSharpSigned
|
||||||
|
{
|
||||||
|
public class TestClass
|
||||||
|
{
|
||||||
|
public static string TestMethod(string input)
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned
|
||||||
|
{
|
||||||
|
public class TestClass
|
||||||
|
{
|
||||||
|
public static string TestMethod(string input)
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
Function Test-PwshSigned {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Tests a signed collection pwsh util.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param ()
|
||||||
|
|
||||||
|
@{
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Test-PwshSigned
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
Function Test-PwshUnsigned {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Tests an unsigned collection pwsh util.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param ()
|
||||||
|
|
||||||
|
@{
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Test-PwshUnsigned
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'inline_signed'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $complex_args.input
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'inline_signed_not_trusted'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $complex_args.input
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
using namespace Ansible.Basic
|
||||||
|
using namespace System.Management.Automation.Language
|
||||||
|
using namespace Invalid.Namespace.That.Does.Not.Exist
|
||||||
|
|
||||||
|
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||||
|
|
||||||
|
$module = [AnsibleModule]::Create($args, @{ options = @{} })
|
||||||
|
|
||||||
|
$module.Result.module_using_namespace = [Parser].FullName
|
||||||
|
|
||||||
|
# Verifies the module is run in its own script scope
|
||||||
|
$var = 'foo'
|
||||||
|
$module.Result.script_var = $script:var
|
||||||
|
|
||||||
|
$missingUsingNamespace = $false
|
||||||
|
try {
|
||||||
|
# exec_wrapper does 'using namespace System.IO'. This ensures that this
|
||||||
|
# hasn't persisted to the module scope and it has it's own set of using
|
||||||
|
# types.
|
||||||
|
$null = [File]::Exists('test')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$missingUsingNamespace = $true
|
||||||
|
}
|
||||||
|
$module.Result.missing_using_namespace = $missingUsingNamespace
|
||||||
|
|
||||||
|
$module.ExitJson()
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'signed'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $complex_args.input
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||||
|
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
|
||||||
|
|
||||||
|
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
|
||||||
|
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
|
||||||
|
|
||||||
|
# Tests builtin C# util
|
||||||
|
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
|
||||||
|
|
||||||
|
# Tests builtin pwsh util
|
||||||
|
Add-CSharpType -AnsibleModule $module -References @'
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace ns.col.module_utils
|
||||||
|
{
|
||||||
|
public class InlineCSharp
|
||||||
|
{
|
||||||
|
public static string TestMethod(string input)
|
||||||
|
{
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'@
|
||||||
|
|
||||||
|
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
$module.Result.builtin_powershell_util = [ns.col.module_utils.InlineCSharp]::TestMethod("value")
|
||||||
|
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
|
||||||
|
$module.Result.powershell_util = Test-PwshSigned
|
||||||
|
|
||||||
|
$module.ExitJson()
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
if ($complex_args.should_fail) {
|
||||||
|
throw "exception here"
|
||||||
|
}
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'skipped'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $complex_args.input
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -CSharpUtil ..module_utils.CSharpUnsigned
|
||||||
|
|
||||||
|
@{
|
||||||
|
changed = $false
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
res = [ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned.TestClass]::TestMethod("value")
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||||
|
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
|
||||||
|
|
||||||
|
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
|
||||||
|
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
|
||||||
|
|
||||||
|
@{
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
builtin_csharp = $null -ne ('Ansible.Basic.AnsibleModule' -as [type])
|
||||||
|
builtin_pwsh = [bool](Get-Command Add-CSharpType -ErrorAction SilentlyContinue)
|
||||||
|
collection_csharp = $null -ne ('ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass' -as [type])
|
||||||
|
collection_pwsh = [bool](Get-Command Test-PwshSigned -ErrorAction SilentlyContinue)
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -PowerShell ..module_utils.PwshUnsigned
|
||||||
|
|
||||||
|
@{
|
||||||
|
changed = $false
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
res = Test-PwshUnsigned
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'unsupported'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
@{
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $args[0]
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
@{
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $args[0]
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
- name: run signed script
|
||||||
|
script: signed.ps1 café
|
||||||
|
register: signed_res
|
||||||
|
|
||||||
|
- name: run unsigned script
|
||||||
|
script: unsigned.ps1 café
|
||||||
|
register: unsigned_res
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
test = 'ns.invalid_manifest.module'
|
||||||
|
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
whoami = [Environment]::UserName
|
||||||
|
ünicode = $complex_args.input
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||||
|
#AnsibleRequires -CSharpUtil ansible_collections.ns.col.plugins.module_utils.CSharpSigned
|
||||||
|
#AnsibleRequires -PowerShell ansible_collections.ns.col.plugins.module_utils.PwshSigned
|
||||||
|
|
||||||
|
# Tests signed util in another trusted collection works
|
||||||
|
|
||||||
|
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
|
||||||
|
|
||||||
|
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||||
|
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
|
||||||
|
$module.Result.powershell_util = Test-PwshSigned
|
||||||
|
|
||||||
|
$module.ExitJson()
|
||||||
15
test/integration/targets/win_app_control/create_manifest.yml
Normal file
15
test/integration/targets/win_app_control/create_manifest.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
- name: create manifest file
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: '{{ manifest_file }}'
|
||||||
|
dest: '{{ local_tmp_dir }}/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1'
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: sign manifest file
|
||||||
|
ansible.builtin.script: >-
|
||||||
|
Set-ManifestSignature.ps1
|
||||||
|
-Path {{ local_tmp_dir ~ "/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1" | quote }}
|
||||||
|
-CertPath {{ local_tmp_dir ~ "/" ~ (cert_name | default("wdac-signing")) ~ ".pfx" | quote }}
|
||||||
|
-CertPass {{ cert_pw | quote }}
|
||||||
|
environment:
|
||||||
|
NO_COLOR: '1'
|
||||||
|
delegate_to: localhost
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
#!/usr/bin/env pwsh
|
||||||
|
|
||||||
|
# 0.5.0 fixed BOM-less encoding issues with Unicode
|
||||||
|
#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' }
|
||||||
|
|
||||||
|
using namespace System.Collections.Generic
|
||||||
|
using namespace System.IO
|
||||||
|
using namespace System.Management.Automation
|
||||||
|
using namespace System.Management.Automation.Language
|
||||||
|
using namespace System.Security.Cryptography.X509Certificates
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$CollectionPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$CertPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$UntrustedCertPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]
|
||||||
|
$CertPass
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
Function New-AnsiblePowerShellSignature {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Creates and signed Ansible content for App Control/WDAC.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This function will generate the powershell_signatures.psd1 manifest and sign
|
||||||
|
it. The manifest file includes all PowerShell/C# module_utils and
|
||||||
|
PowerShell modules in the collection(s) specified. It will also create the
|
||||||
|
'*.authenticode' signature file for the exec_wrapper.ps1 used inside
|
||||||
|
Ansible itself.
|
||||||
|
|
||||||
|
.PARAMETER Certificate
|
||||||
|
The certificate to use for signing the content.
|
||||||
|
|
||||||
|
.PARAMETER Collection
|
||||||
|
The collection(s) to sign. This is set to ansible.builtin by default but
|
||||||
|
can be overriden to include other collections like ansible.windows.
|
||||||
|
|
||||||
|
.PARAMETER Skip
|
||||||
|
A list of plugins to skip by the fully qualified name. Plugins skipped will
|
||||||
|
not be included in the signed manifest. This means that modules will be run
|
||||||
|
in CLM mode and module_utils will be skipped entirely.
|
||||||
|
|
||||||
|
The values in the list should be the fully qualified name of the plugin as
|
||||||
|
referenced in Ansible. The value can also optionally include the extension
|
||||||
|
of the file if the FQN is ambigious, e.g. collection util that has both a
|
||||||
|
PowerShell and C# util of the same name.
|
||||||
|
|
||||||
|
Here are some examples for the various content types:
|
||||||
|
|
||||||
|
# Ansible Builtin Modules
|
||||||
|
'ansible.builtin.module_name'
|
||||||
|
|
||||||
|
# Ansible Builtin ModuleUtil
|
||||||
|
'Ansible.ModuleUtils.PowerShellUtil'
|
||||||
|
'Ansible.CSharpUtil'
|
||||||
|
|
||||||
|
# Collection Modules
|
||||||
|
'namespace.name.module_name'
|
||||||
|
|
||||||
|
# Collection ModuleUtils
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil'
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil.psm1'
|
||||||
|
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil'
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil.cs'
|
||||||
|
|
||||||
|
.PARAMETER Unsupported
|
||||||
|
A list of plugins to be marked as unsupported in the manifest and will
|
||||||
|
error when being run. List -Skip, the values here are the fully qualified
|
||||||
|
name of the plugin as referenced in Ansible.
|
||||||
|
|
||||||
|
.PARAMETER TimeStampServer
|
||||||
|
Optional authenticode timestamp server to use when signing the content.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Signs just the content included in Ansible.
|
||||||
|
|
||||||
|
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||||
|
New-AnsiblePowerShellSignature -Certificate $cert
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Signs just the content include in Ansible and the ansible.windows collection
|
||||||
|
|
||||||
|
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||||
|
New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.builtin, ansible.windows
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Signs just the content in the ansible.windows collection
|
||||||
|
|
||||||
|
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||||
|
New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.windows
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Signs content but skips the specified modules and module_utils
|
||||||
|
$skip = @(
|
||||||
|
# Skips the module specified
|
||||||
|
'namespace.name.module'
|
||||||
|
|
||||||
|
# Skips the module_utils specified
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil'
|
||||||
|
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil'
|
||||||
|
|
||||||
|
# Skips signing the file specified
|
||||||
|
'ansible_collections.namespace.name.plugins.plugin_utils.powershell.file.ps1'
|
||||||
|
)
|
||||||
|
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||||
|
New-AnsiblePowerShellSignature -Certificate $cert -Collection namespace.name -Skip $skip
|
||||||
|
|
||||||
|
.NOTES
|
||||||
|
This function requires Ansible to be installed and available in the PATH so
|
||||||
|
it can find the Ansible installation and collection paths.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(
|
||||||
|
Mandatory
|
||||||
|
)]
|
||||||
|
[X509Certificate2]
|
||||||
|
$Certificate,
|
||||||
|
|
||||||
|
[Parameter(
|
||||||
|
ValueFromPipeline,
|
||||||
|
ValueFromPipelineByPropertyName
|
||||||
|
)]
|
||||||
|
[string[]]
|
||||||
|
$Collection = "ansible.builtin",
|
||||||
|
|
||||||
|
[Parameter(
|
||||||
|
ValueFromPipelineByPropertyName
|
||||||
|
)]
|
||||||
|
[string[]]
|
||||||
|
$Skip = @(),
|
||||||
|
|
||||||
|
[Parameter(
|
||||||
|
ValueFromPipelineByPropertyName
|
||||||
|
)]
|
||||||
|
[string[]]
|
||||||
|
$Unsupported = @(),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]
|
||||||
|
$TimeStampServer
|
||||||
|
)
|
||||||
|
|
||||||
|
begin {
|
||||||
|
Write-Verbose "Attempting to get ansible-config dump"
|
||||||
|
$configRaw = ansible-config dump --format json --type base 2>&1
|
||||||
|
if ($LASTEXITCODE) {
|
||||||
|
$err = [ErrorRecord]::new(
|
||||||
|
[Exception]::new("Failed to get Ansible configuration, RC: ${LASTEXITCODE} - $configRaw"),
|
||||||
|
'FailedToGetAnsibleConfiguration',
|
||||||
|
[ErrorCategory]::NotSpecified,
|
||||||
|
$null)
|
||||||
|
$PSCmdlet.ThrowTerminatingError($err)
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $configRaw | ConvertFrom-Json
|
||||||
|
$collectionsPaths = @($config | Where-Object name -EQ 'COLLECTIONS_PATHS' | ForEach-Object value)
|
||||||
|
Write-Verbose "Collections paths to be searched: [$($collectionsPaths -join ":")]"
|
||||||
|
|
||||||
|
$signParams = @{
|
||||||
|
Certificate = $Certificate
|
||||||
|
HashAlgorithm = 'SHA256'
|
||||||
|
}
|
||||||
|
if ($TimeStampServer) {
|
||||||
|
$signParams.TimeStampServer = $TimeStampServer
|
||||||
|
}
|
||||||
|
|
||||||
|
$checked = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
|
Function New-HashEntry {
|
||||||
|
[OutputType([PSObject])]
|
||||||
|
[CmdletBinding()]
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory, ValueFromPipeline)]
|
||||||
|
[FileInfo]
|
||||||
|
$File,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[AllowEmptyString()]
|
||||||
|
[string]
|
||||||
|
$PluginBase,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[string[]]
|
||||||
|
$Unsupported = @(),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[AllowEmptyCollection()]
|
||||||
|
[string[]]
|
||||||
|
$Skip = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
process {
|
||||||
|
$nameWithoutExt = [string]::IsNullOrEmpty($PluginBase) ? $File.BaseName : "$PluginBase.$($File.BaseName)"
|
||||||
|
$nameWithExt = "$nameWithoutExt$($File.Extension)"
|
||||||
|
|
||||||
|
$mode = 'Trusted'
|
||||||
|
if ($nameWithoutExt -in $Skip -or $nameWithExt -in $Skip) {
|
||||||
|
Write-Verbose "Skipping plugin '$nameWithExt' as it is in the supplied skip list"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elseif ($nameWithoutExt -in $Unsupported -or $nameWithExt -in $Unsupported) {
|
||||||
|
Write-Verbose "Marking plugin '$nameWithExt' as unsupported as it is in the unsupported list"
|
||||||
|
$mode = 'Unsupported'
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Hashing plugin '$nameWithExt'"
|
||||||
|
$hash = Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256
|
||||||
|
[PSCustomObject]@{
|
||||||
|
Name = $nameWithExt
|
||||||
|
Hash = $hash.Hash
|
||||||
|
Mode = $mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process {
|
||||||
|
$newHashParams = @{
|
||||||
|
Skip = $Skip
|
||||||
|
Unsupported = $Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($c in $Collection) {
|
||||||
|
try {
|
||||||
|
if (-not $checked.Add($c)) {
|
||||||
|
Write-Verbose "Skipping already processed collection $c"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$metaPath = $null
|
||||||
|
$pathsToSign = [List[FileInfo]]::new()
|
||||||
|
$hashedPaths = [List[PSObject]]::new()
|
||||||
|
|
||||||
|
if ($c -eq 'ansible.builtin') {
|
||||||
|
Write-Verbose "Attempting to get Ansible installation path"
|
||||||
|
$ansiblePath = python -c "import ansible; print(ansible.__file__)" 2>&1
|
||||||
|
if ($LASTEXITCODE) {
|
||||||
|
throw "Failed to find Ansible installation path, RC: ${LASTEXITCODE} - $ansiblePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$ansibleBase = Split-Path -Path $ansiblePath -Parent
|
||||||
|
$metaPath = [Path]::Combine($ansibleBase, 'config')
|
||||||
|
|
||||||
|
$execWrapper = Get-Item -LiteralPath ([Path]::Combine($ansibleBase, 'executor', 'powershell', 'exec_wrapper.ps1'))
|
||||||
|
$pathsToSign.Add($execWrapper)
|
||||||
|
|
||||||
|
$ansiblePwshContent = [PSObject[]]@(
|
||||||
|
# These are needed for Ansible and cannot be skipped
|
||||||
|
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'executor', 'powershell', '*.ps1')) -Exclude "bootstrap_wrapper.ps1" |
|
||||||
|
New-HashEntry -PluginBase "ansible.executor.powershell"
|
||||||
|
|
||||||
|
# Builtin utils are special where the filename is their FQN
|
||||||
|
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'csharp', '*.cs')) |
|
||||||
|
New-HashEntry -PluginBase "" @newHashParams
|
||||||
|
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'powershell', '*.psm1')) |
|
||||||
|
New-HashEntry -PluginBase "" @newHashParams
|
||||||
|
|
||||||
|
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'modules', '*.ps1')) |
|
||||||
|
New-HashEntry -PluginBase $c @newHashParams
|
||||||
|
)
|
||||||
|
$hashedPaths.AddRange($ansiblePwshContent)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Attempting to get collection path for $c"
|
||||||
|
$namespace, $name, $remaining = $c.ToLowerInvariant() -split '\.'
|
||||||
|
if (-not $name -or $remaining) {
|
||||||
|
throw "Invalid collection name '$c', must be in the format 'namespace.name'"
|
||||||
|
}
|
||||||
|
|
||||||
|
$foundPath = $null
|
||||||
|
foreach ($path in $collectionsPaths) {
|
||||||
|
$collectionPath = [Path]::Combine($path, 'ansible_collections', $namespace, $name)
|
||||||
|
|
||||||
|
Write-Verbose "Checking if collection $c exists in '$collectionPath'"
|
||||||
|
if (Test-Path -LiteralPath $collectionPath) {
|
||||||
|
$foundPath = $collectionPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $foundPath) {
|
||||||
|
throw "Failed to find collection path for $c"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Using collection path '$foundPath' for $c"
|
||||||
|
|
||||||
|
$metaPath = [Path]::Combine($foundPath, 'meta')
|
||||||
|
|
||||||
|
$collectionPwshContent = [PSObject[]]@(
|
||||||
|
$utilPath = [Path]::Combine($foundPath, 'plugins', 'module_utils')
|
||||||
|
if (Test-Path -LiteralPath $utilPath) {
|
||||||
|
Get-ChildItem -LiteralPath $utilPath | Where-Object Extension -In '.cs', '.psm1' |
|
||||||
|
New-HashEntry -PluginBase "ansible_collections.$c.plugins.module_utils" @newHashParams
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulePath = [Path]::Combine($foundPath, 'plugins', 'modules')
|
||||||
|
if (Test-Path -LiteralPath $modulePath) {
|
||||||
|
Get-ChildItem -LiteralPath $modulePath | Where-Object Extension -EQ '.ps1' |
|
||||||
|
New-HashEntry -PluginBase $c @newHashParams
|
||||||
|
}
|
||||||
|
)
|
||||||
|
$hashedPaths.AddRange($collectionPwshContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $metaPath)) {
|
||||||
|
Write-Verbose "Creating meta path '$metaPath'"
|
||||||
|
New-Item -Path $metaPath -ItemType Directory -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = @(
|
||||||
|
'@{'
|
||||||
|
' Version = 1'
|
||||||
|
' HashList = @('
|
||||||
|
foreach ($content in $hashedPaths) {
|
||||||
|
# To avoid encoding problems with Authenticode and non-ASCII
|
||||||
|
# characters, we escape them as Unicode code points. We also
|
||||||
|
# escape some ASCII control characters that can cause escaping
|
||||||
|
# problems like newlines.
|
||||||
|
$escapedName = [Regex]::Replace(
|
||||||
|
$content.Name,
|
||||||
|
'([^\u0020-\u007F])',
|
||||||
|
{ '\u{0:x4}' -f ([uint16][char]$args[0].Value) })
|
||||||
|
|
||||||
|
$escapedHash = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Hash)
|
||||||
|
$escapedMode = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Mode)
|
||||||
|
" # $escapedName"
|
||||||
|
" @{"
|
||||||
|
" Hash = '$escapedHash'"
|
||||||
|
" Mode = '$escapedMode'"
|
||||||
|
" }"
|
||||||
|
}
|
||||||
|
' )'
|
||||||
|
'}'
|
||||||
|
) -join "`n"
|
||||||
|
$manifestPath = [Path]::Combine($metaPath, 'powershell_signatures.psd1')
|
||||||
|
Write-Verbose "Creating and signing manifest for $c at '$manifestPath'"
|
||||||
|
Set-Content -LiteralPath $manifestPath -Value $manifest -NoNewline
|
||||||
|
|
||||||
|
Set-OpenAuthenticodeSignature -LiteralPath $manifestPath @signParams
|
||||||
|
|
||||||
|
$pathsToSign | ForEach-Object -Process {
|
||||||
|
$tempPath = Join-Path $_.DirectoryName "$($_.BaseName)_tmp.ps1"
|
||||||
|
$_ | Copy-Item -Destination $tempPath -Force
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-Verbose "Signing script '$($_.FullName)'"
|
||||||
|
Set-OpenAuthenticodeSignature -LiteralPath $tempPath @signParams
|
||||||
|
|
||||||
|
$signedContent = Get-Content -LiteralPath $tempPath -Raw
|
||||||
|
$sigIndex = $signedContent.LastIndexOf("`r`n# SIG # Begin signature block`r`n")
|
||||||
|
if ($sigIndex -eq -1) {
|
||||||
|
throw "Failed to find signature block in $($_.FullName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ignore the first and last \r\n when extracting the signature
|
||||||
|
$sigIndex += 2
|
||||||
|
$signature = $signedContent.Substring($sigIndex, $signedContent.Length - $sigIndex - 2)
|
||||||
|
$sigPath = Join-Path $_.DirectoryName "$($_.Name).authenticode"
|
||||||
|
|
||||||
|
Write-Verbose "Creating signature file at '$sigPath'"
|
||||||
|
Set-Content -LiteralPath $sigPath -Value $signature -NoNewline
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$tempPath | Remove-Item -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$_.ErrorDetails = "Failed to process collection ${c}: $_"
|
||||||
|
$PSCmdlet.WriteError($_)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cert = [X509Certificate2]::new($CertPath, $CertPass)
|
||||||
|
$untrustedCert = [X509Certificate2]::new($UntrustedCertPath, $CertPass)
|
||||||
|
|
||||||
|
$sigParams = @{
|
||||||
|
Certificate = $cert
|
||||||
|
Collection = 'ansible.builtin', 'ansible.windows', 'ns.col', 'ns.module_util_ref'
|
||||||
|
Skip = @(
|
||||||
|
'ns.col.skipped'
|
||||||
|
'ns.col.inline_signed'
|
||||||
|
'ns.col.inline_signed_not_trusted'
|
||||||
|
'ns.col.unsigned_module_with_util'
|
||||||
|
'ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned'
|
||||||
|
'ansible_collections.ns.col.plugins.module_utils.PwshUnsigned'
|
||||||
|
)
|
||||||
|
Unsupported = 'ns.col.unsupported'
|
||||||
|
}
|
||||||
|
New-AnsiblePowerShellSignature @sigParams
|
||||||
|
|
||||||
|
@(
|
||||||
|
"$CollectionPath/plugins/modules/inline_signed.ps1"
|
||||||
|
"$CollectionPath/roles/app_control_script/files/signed.ps1"
|
||||||
|
) | Set-OpenAuthenticodeSignature -Certificate $cert -HashAlgorithm SHA256
|
||||||
|
|
||||||
|
@(
|
||||||
|
"$CollectionPath/plugins/modules/inline_signed_not_trusted.ps1"
|
||||||
|
) | Set-OpenAuthenticodeSignature -Certificate $untrustedCert -HashAlgorithm SHA256
|
||||||
26
test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1
Executable file
26
test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1
Executable file
|
|
@ -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
|
||||||
146
test/integration/targets/win_app_control/main.yml
Normal file
146
test/integration/targets/win_app_control/main.yml
Normal file
|
|
@ -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
|
||||||
18
test/integration/targets/win_app_control/runme.sh
Executable file
18
test/integration/targets/win_app_control/runme.sh
Executable file
|
|
@ -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}" \
|
||||||
|
"${@}"
|
||||||
116
test/integration/targets/win_app_control/setup.yml
Normal file
116
test/integration/targets/win_app_control/setup.yml
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
- name: setup test facts
|
||||||
|
set_fact:
|
||||||
|
cert_pw: "{{ 'password123!' + lookup('password', '/dev/null chars=ascii_letters,digits length=8') }}"
|
||||||
|
|
||||||
|
- name: setup WDAC certificates
|
||||||
|
win_shell: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$testPrefix = 'Ansible-WDAC'
|
||||||
|
$certPassword = ConvertTo-SecureString -String '{{ cert_pw }}' -Force -AsPlainText
|
||||||
|
$remoteTmpDir = '{{ remote_tmp_dir }}'
|
||||||
|
|
||||||
|
$enhancedKeyUsage = [Security.Cryptography.OidCollection]::new()
|
||||||
|
$null = $enhancedKeyUsage.Add('1.3.6.1.5.5.7.3.3') # Code Signing
|
||||||
|
$caParams = @{
|
||||||
|
Extension = @(
|
||||||
|
[Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true),
|
||||||
|
[Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new('KeyCertSign', $false),
|
||||||
|
[Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension ]::new($enhancedKeyUsage, $false)
|
||||||
|
)
|
||||||
|
CertStoreLocation = 'Cert:\CurrentUser\My'
|
||||||
|
NotAfter = (Get-Date).AddDays(1)
|
||||||
|
Type = 'Custom'
|
||||||
|
}
|
||||||
|
$ca = New-SelfSignedCertificate @caParams -Subject "CN=$testPrefix-Root"
|
||||||
|
|
||||||
|
$certParams = @{
|
||||||
|
CertStoreLocation = 'Cert:\CurrentUser\My'
|
||||||
|
KeyUsage = 'DigitalSignature'
|
||||||
|
TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
|
||||||
|
Type = 'Custom'
|
||||||
|
}
|
||||||
|
$cert = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Signed" -Signer $ca
|
||||||
|
$null = $cert | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\signing.pfx"
|
||||||
|
$cert.Export('Cert') | Set-Content -LiteralPath "$remoteTmpDir\signing.cer" -Encoding Byte
|
||||||
|
|
||||||
|
$certUntrusted = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Untrusted"
|
||||||
|
$null = $certUntrusted | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\untrusted.pfx"
|
||||||
|
|
||||||
|
$caWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ca.Export('Cert'))
|
||||||
|
$certWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.Export('Cert'))
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($ca.Thumbprint)" -DeleteKey -Force
|
||||||
|
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey -Force
|
||||||
|
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certUntrusted.Thumbprint)" -DeleteKey -Force
|
||||||
|
|
||||||
|
$root = Get-Item Cert:\LocalMachine\Root
|
||||||
|
$root.Open('ReadWrite')
|
||||||
|
$root.Add($caWithoutKey)
|
||||||
|
$root.Dispose()
|
||||||
|
|
||||||
|
$trustedPublisher = Get-Item Cert:\LocalMachine\TrustedPublisher
|
||||||
|
$trustedPublisher.Open('ReadWrite')
|
||||||
|
$trustedPublisher.Add($certWithoutKey)
|
||||||
|
$trustedPublisher.Dispose()
|
||||||
|
|
||||||
|
@{
|
||||||
|
ca_thumbprint = $caWithoutKey.Thumbprint
|
||||||
|
thumbprint = $certWithoutKey.Thumbprint
|
||||||
|
untrusted_thumbprint = $certUntrusted.Thumbprint
|
||||||
|
} | ConvertTo-Json
|
||||||
|
register: cert_info_raw
|
||||||
|
become: true
|
||||||
|
become_method: runas
|
||||||
|
vars:
|
||||||
|
ansible_become_user: '{{ ansible_user }}'
|
||||||
|
ansible_become_pass: '{{ ansible_password | default(ansible_test_connection_password) }}'
|
||||||
|
|
||||||
|
- name: parse raw cert_info
|
||||||
|
set_fact:
|
||||||
|
cert_info: "{{ cert_info_raw.stdout | from_json }}"
|
||||||
|
|
||||||
|
- name: fetch signing certificates
|
||||||
|
fetch:
|
||||||
|
src: '{{ remote_tmp_dir }}\{{ item }}.pfx'
|
||||||
|
dest: '{{ local_tmp_dir }}/wdac-{{ item }}.pfx'
|
||||||
|
flat: yes
|
||||||
|
loop:
|
||||||
|
- signing
|
||||||
|
- untrusted
|
||||||
|
|
||||||
|
- name: install OpenAuthenticode
|
||||||
|
shell: |
|
||||||
|
if (-not (Get-Module -Name OpenAuthenticode -ListAvailable | Where-Object Version -ge '0.5.0')) {
|
||||||
|
$url = 'https://ansible-ci-files.s3.us-east-1.amazonaws.com/test/integration/targets/win_app_control/openauthenticode.0.6.1.nupkg'
|
||||||
|
Invoke-WebRequest -Uri $url -OutFile '{{ local_tmp_dir }}/openauthenticode.0.6.1.nupkg'
|
||||||
|
|
||||||
|
Register-PSResourceRepository -Name AnsibleTemp -Trusted -Uri '{{ local_tmp_dir }}'
|
||||||
|
try {
|
||||||
|
Install-PSResource -Name OpenAuthenticode -Repository AnsibleTemp
|
||||||
|
} finally {
|
||||||
|
Unregister-PSResourceRepository -Name AnsibleTemp
|
||||||
|
}
|
||||||
|
|
||||||
|
$true
|
||||||
|
} else {
|
||||||
|
$false
|
||||||
|
}
|
||||||
|
args:
|
||||||
|
executable: pwsh
|
||||||
|
register: open_auth_install
|
||||||
|
changed_when: open_auth_install.stdout | bool
|
||||||
|
notify: remove openauthenticode
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: sign Ansible content
|
||||||
|
script: >-
|
||||||
|
New-AnsiblePowerShellSignature.ps1
|
||||||
|
-CollectionPath {{ local_tmp_dir ~ "/ansible_collections/ns/col" | quote }}
|
||||||
|
-CertPath {{ local_tmp_dir ~ "/wdac-signing.pfx" | quote }}
|
||||||
|
-UntrustedCertPath {{ local_tmp_dir ~ "/wdac-untrusted.pfx" | quote }}
|
||||||
|
-CertPass {{ cert_pw | quote }}
|
||||||
|
-Verbose
|
||||||
|
environment:
|
||||||
|
NO_COLOR: '1'
|
||||||
|
delegate_to: localhost
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@{
|
||||||
|
Version = 2
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }}'
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
'failure'
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@{
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }}'
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }} - extra'
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }}'
|
||||||
|
Mode = 'Other'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }}'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = '{{ module_hash }}'
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@{
|
||||||
|
Version = 1
|
||||||
|
HashList = @(
|
||||||
|
@{
|
||||||
|
Hash = "$env:TEST_HASH"
|
||||||
|
Mode = 'Trusted'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
215
test/integration/targets/win_app_control/test_enabled.yml
Normal file
215
test/integration/targets/win_app_control/test_enabled.yml
Normal file
|
|
@ -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'}"
|
||||||
154
test/integration/targets/win_app_control/test_manifest.yml
Normal file
154
test/integration/targets/win_app_control/test_manifest.yml
Normal file
|
|
@ -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")
|
||||||
148
test/integration/targets/win_app_control/test_not_enabled.yml
Normal file
148
test/integration/targets/win_app_control/test_not_enabled.yml
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
# When App Control is not enabled we expect modules even if signed or unsigned should
|
||||||
|
# run without any changes in FullLanguageMode.
|
||||||
|
|
||||||
|
- name: run signed module
|
||||||
|
ns.col.signed:
|
||||||
|
input: café
|
||||||
|
register: signed_res
|
||||||
|
|
||||||
|
- name: assert run signed module
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- signed_res.language_mode == 'FullLanguage'
|
||||||
|
- signed_res.test == 'signed'
|
||||||
|
- signed_res.whoami == current_user
|
||||||
|
- signed_res.ünicode == 'café'
|
||||||
|
|
||||||
|
- name: run inline signed module
|
||||||
|
ns.col.inline_signed:
|
||||||
|
input: café
|
||||||
|
register: inline_signed_res
|
||||||
|
|
||||||
|
- name: assert run inline signed module
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- inline_signed_res.language_mode == 'FullLanguage'
|
||||||
|
- inline_signed_res.test == 'inline_signed'
|
||||||
|
- inline_signed_res.whoami == current_user
|
||||||
|
- inline_signed_res.ünicode == 'café'
|
||||||
|
|
||||||
|
- name: run inline signed module that is not trusted
|
||||||
|
ns.col.inline_signed_not_trusted:
|
||||||
|
input: café
|
||||||
|
register: inline_signed_not_trusted_res
|
||||||
|
|
||||||
|
- name: assert run inline signed module
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- inline_signed_not_trusted_res.language_mode == 'FullLanguage'
|
||||||
|
- inline_signed_not_trusted_res.test == 'inline_signed_not_trusted'
|
||||||
|
- inline_signed_not_trusted_res.whoami == current_user
|
||||||
|
- inline_signed_not_trusted_res.ünicode == 'café'
|
||||||
|
|
||||||
|
- name: run signed module to test exec wrapper scope
|
||||||
|
ns.col.scope:
|
||||||
|
register: scoped_res
|
||||||
|
|
||||||
|
- name: assert run signed module to test exec wrapper scope
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- scoped_res.missing_using_namespace == True
|
||||||
|
- scoped_res.module_using_namespace == 'System.Management.Automation.Language.Parser'
|
||||||
|
- scoped_res.script_var == 'foo'
|
||||||
|
|
||||||
|
- name: run module marked as skipped
|
||||||
|
ns.col.skipped:
|
||||||
|
input: café
|
||||||
|
register: skipped_res
|
||||||
|
|
||||||
|
- name: assert run module marked as skipped
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- skipped_res.language_mode == 'FullLanguage'
|
||||||
|
- skipped_res.test == 'skipped'
|
||||||
|
- skipped_res.whoami == current_user
|
||||||
|
- skipped_res.ünicode == 'café'
|
||||||
|
|
||||||
|
- name: run module marked as unsupported
|
||||||
|
ns.col.unsupported:
|
||||||
|
register: unsupported_res
|
||||||
|
|
||||||
|
- name: assert run module marked as unsupported
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- unsupported_res.language_mode == 'FullLanguage'
|
||||||
|
- unsupported_res.test == 'unsupported'
|
||||||
|
- unsupported_res.whoami == current_user
|
||||||
|
|
||||||
|
- name: run module with signed utils
|
||||||
|
ns.col.signed_module_util:
|
||||||
|
register: signed_util_res
|
||||||
|
|
||||||
|
- name: assert run module with signed utils
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- signed_util_res.language_mode == 'FullLanguage'
|
||||||
|
- signed_util_res.builtin_powershell_util == 'value'
|
||||||
|
- signed_util_res.csharp_util == 'value'
|
||||||
|
- signed_util_res.powershell_util.language_mode == 'FullLanguage'
|
||||||
|
|
||||||
|
- name: run module with unsigned C# util
|
||||||
|
ns.col.unsigned_csharp_util:
|
||||||
|
register: unsigned_csharp_util_res
|
||||||
|
|
||||||
|
- name: assert run module with unsigned C# util
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- unsigned_csharp_util_res.language_mode == 'FullLanguage'
|
||||||
|
- unsigned_csharp_util_res.res == 'value'
|
||||||
|
|
||||||
|
- name: run module with unsigned Pwsh util
|
||||||
|
ns.col.unsigned_pwsh_util:
|
||||||
|
register: unsigned_pwsh_util_res
|
||||||
|
|
||||||
|
- name: assert run module with unsigned Pwsh util
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- unsigned_pwsh_util_res.language_mode == 'FullLanguage'
|
||||||
|
- unsigned_pwsh_util_res.res.language_mode == 'FullLanguage'
|
||||||
|
|
||||||
|
- name: run unsigned module with utils
|
||||||
|
ns.col.unsigned_module_with_util:
|
||||||
|
register: unsigned_module_with_util_res
|
||||||
|
|
||||||
|
- name: assert run unsigned module with utils
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- unsigned_module_with_util_res.language_mode == 'FullLanguage'
|
||||||
|
- unsigned_module_with_util_res.builtin_csharp == True
|
||||||
|
- unsigned_module_with_util_res.builtin_pwsh == True
|
||||||
|
- unsigned_module_with_util_res.collection_csharp == True
|
||||||
|
- unsigned_module_with_util_res.collection_pwsh == True
|
||||||
|
|
||||||
|
- name: run script role
|
||||||
|
import_role:
|
||||||
|
name: ns.col.app_control_script
|
||||||
|
|
||||||
|
- name: assert script role result
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- signed_res.rc == 0
|
||||||
|
- (signed_res.stdout | from_json).language_mode == 'FullLanguage'
|
||||||
|
- (signed_res.stdout | from_json).whoami == current_user
|
||||||
|
- (signed_res.stdout | from_json).ünicode == 'café'
|
||||||
|
- unsigned_res.rc == 0
|
||||||
|
- (unsigned_res.stdout | from_json).language_mode == 'FullLanguage'
|
||||||
|
- (unsigned_res.stdout | from_json).whoami == current_user
|
||||||
|
- (unsigned_res.stdout | from_json).ünicode == 'café'
|
||||||
|
|
||||||
|
- name: run signed module with signed module util in another collection
|
||||||
|
ns.module_util_ref.module:
|
||||||
|
register: cross_util_res
|
||||||
|
|
||||||
|
- name: assert run signed module with signed module utils in another collection
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- cross_util_res.language_mode == 'FullLanguage'
|
||||||
|
- cross_util_res.csharp_util == 'value'
|
||||||
|
- "cross_util_res.powershell_util == {'language_mode': 'FullLanguage'}"
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!powershell
|
||||||
|
|
||||||
|
#AnsibleRequires -Wrapper
|
||||||
|
|
||||||
|
@{
|
||||||
|
changed = $false
|
||||||
|
complex_args = $complex_args
|
||||||
|
} | ConvertTo-Json -Depth 99
|
||||||
|
|
@ -328,3 +328,14 @@
|
||||||
- exec_wrapper_scope.util_res.module_using_namespace == 'System.Security.Cryptography.X509Certificates.X509Certificate2'
|
- exec_wrapper_scope.util_res.module_using_namespace == 'System.Security.Cryptography.X509Certificates.X509Certificate2'
|
||||||
- exec_wrapper_scope.util_res.missing_using_namespace == True
|
- exec_wrapper_scope.util_res.missing_using_namespace == True
|
||||||
- exec_wrapper_scope.util_res.script_var == 'bar'
|
- 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'}"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
'ünicode'
|
||||||
|
|
@ -338,3 +338,12 @@
|
||||||
# SSH includes debug output in stderr, and WinRM on 2016 includes a trailing newline
|
# 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
|
# 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")
|
- script_stderr.stderr is search("stderr 1\r\nstderr 2\r\n")
|
||||||
|
|
||||||
|
- name: run script with non-ASCII contents
|
||||||
|
script: test_script_unicode.ps1
|
||||||
|
register: script_unicode
|
||||||
|
|
||||||
|
- name: assert run script with non-ASCII contents
|
||||||
|
assert:
|
||||||
|
that:
|
||||||
|
- script_unicode.stdout | trim == 'ünicode'
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,9 @@ test/integration/targets/template/templates/encoding_1252.j2 no-smart-quotes
|
||||||
test/integration/targets/template/templates/encoding_1252.j2 no-unwanted-characters
|
test/integration/targets/template/templates/encoding_1252.j2 no-unwanted-characters
|
||||||
test/integration/targets/unicode/unicode.yml no-smart-quotes
|
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/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/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_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
|
test/integration/targets/win_fetch/tasks/main.yml no-smart-quotes # We are explictly testing smart quotes in the file name to fetch
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from ansible.module_utils.common.text.converters import to_bytes
|
||||||
from ansible._internal._datatag._tags import TrustedAsTemplate
|
from ansible._internal._datatag._tags import TrustedAsTemplate
|
||||||
from ansible.playbook.play_context import PlayContext
|
from ansible.playbook.play_context import PlayContext
|
||||||
from ansible.plugins.action import ActionBase
|
from ansible.plugins.action import ActionBase
|
||||||
|
from ansible.plugins.shell import _ShellCommand
|
||||||
from ansible.vars.clean import clean_facts
|
from ansible.vars.clean import clean_facts
|
||||||
from ansible.template import Templar
|
from ansible.template import Templar
|
||||||
from ansible.plugins import loader
|
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
|
# create a mock connection, so we don't actually try and connect to things
|
||||||
mock_connection = MagicMock()
|
mock_connection = MagicMock()
|
||||||
mock_connection.transport = 'ssh'
|
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.join_path.side_effect = os.path.join
|
||||||
mock_connection._shell.get_option = get_shell_opt
|
mock_connection._shell.get_option = get_shell_opt
|
||||||
mock_connection._shell.HOMES_RE = re.compile(r'(\'|\")?(~|\$HOME)(.*)')
|
mock_connection._shell.HOMES_RE = re.compile(r'(\'|\")?(~|\$HOME)(.*)')
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user