mirror of https://github.com/ansible/ansible.git
Add support for Windows App Control/WDAC (#84898)
* Add support for Windows App Control/WDAC Adds preview support for Windows App Control, formerly known as WDAC. This is a tech preview feature and is designed to test out improvements needed in future versions of Ansible. * Use psd1 and parse it through the Ast to avoid any unexpected execution results * Add tests for various manifest permutations * Ignore test shebang failure * Apply suggestions from code review Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com> * Use more flexible test expectations * Add type annotations for shell functions --------- Co-authored-by: Matt Davis <6775756+nitzmahone@users.noreply.github.com>pull/85193/head
parent
e82be177cd
commit
75f7b2267d
@ -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.
|
||||
@ -0,0 +1,20 @@
|
||||
# (c) 2025 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$Path
|
||||
)
|
||||
|
||||
$userProfile = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
|
||||
if ($Path -eq '~') {
|
||||
$userProfile
|
||||
}
|
||||
elseif ($Path.StartsWith(('~\'))) {
|
||||
Join-Path -Path $userProfile -ChildPath $Path.Substring(2)
|
||||
}
|
||||
else {
|
||||
$Path
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
# (c) 2025 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$Directory,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$Name
|
||||
)
|
||||
|
||||
$path = [Environment]::ExpandEnvironmentVariables($Directory)
|
||||
$tmp = New-Item -Path $path -Name $Name -ItemType Directory
|
||||
$tmp.FullName
|
||||
@ -0,0 +1,5 @@
|
||||
shippable/windows/group1
|
||||
shippable/windows/smoketest
|
||||
needs/target/collection
|
||||
needs/target/setup_remote_tmp_dir
|
||||
destructive # modifies files in the ansible installation dir
|
||||
@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace ansible_collections.ns.col.plugins.module_utils.CSharpSigned
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public static string TestMethod(string input)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned
|
||||
{
|
||||
public class TestClass
|
||||
{
|
||||
public static string TestMethod(string input)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
Function Test-PwshSigned {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests a signed collection pwsh util.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param ()
|
||||
|
||||
@{
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Test-PwshSigned
|
||||
@ -0,0 +1,14 @@
|
||||
Function Test-PwshUnsigned {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tests an unsigned collection pwsh util.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param ()
|
||||
|
||||
@{
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Test-PwshUnsigned
|
||||
@ -0,0 +1,10 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
test = 'inline_signed'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $complex_args.input
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,10 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
test = 'inline_signed_not_trusted'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $complex_args.input
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,29 @@
|
||||
#!powershell
|
||||
|
||||
using namespace Ansible.Basic
|
||||
using namespace System.Management.Automation.Language
|
||||
using namespace Invalid.Namespace.That.Does.Not.Exist
|
||||
|
||||
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||
|
||||
$module = [AnsibleModule]::Create($args, @{ options = @{} })
|
||||
|
||||
$module.Result.module_using_namespace = [Parser].FullName
|
||||
|
||||
# Verifies the module is run in its own script scope
|
||||
$var = 'foo'
|
||||
$module.Result.script_var = $script:var
|
||||
|
||||
$missingUsingNamespace = $false
|
||||
try {
|
||||
# exec_wrapper does 'using namespace System.IO'. This ensures that this
|
||||
# hasn't persisted to the module scope and it has it's own set of using
|
||||
# types.
|
||||
$null = [File]::Exists('test')
|
||||
}
|
||||
catch {
|
||||
$missingUsingNamespace = $true
|
||||
}
|
||||
$module.Result.missing_using_namespace = $missingUsingNamespace
|
||||
|
||||
$module.ExitJson()
|
||||
@ -0,0 +1,10 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
test = 'signed'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $complex_args.input
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,33 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
|
||||
|
||||
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
|
||||
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
|
||||
|
||||
# Tests builtin C# util
|
||||
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
|
||||
|
||||
# Tests builtin pwsh util
|
||||
Add-CSharpType -AnsibleModule $module -References @'
|
||||
using System;
|
||||
|
||||
namespace ns.col.module_utils
|
||||
{
|
||||
public class InlineCSharp
|
||||
{
|
||||
public static string TestMethod(string input)
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
'@
|
||||
|
||||
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
$module.Result.builtin_powershell_util = [ns.col.module_utils.InlineCSharp]::TestMethod("value")
|
||||
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
|
||||
$module.Result.powershell_util = Test-PwshSigned
|
||||
|
||||
$module.ExitJson()
|
||||
@ -0,0 +1,14 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
if ($complex_args.should_fail) {
|
||||
throw "exception here"
|
||||
}
|
||||
|
||||
@{
|
||||
test = 'skipped'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $complex_args.input
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,9 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -CSharpUtil ..module_utils.CSharpUnsigned
|
||||
|
||||
@{
|
||||
changed = $false
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
res = [ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned.TestClass]::TestMethod("value")
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,15 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||
#AnsibleRequires -CSharpUtil ..module_utils.CSharpSigned
|
||||
|
||||
#AnsibleRequires -PowerShell Ansible.ModuleUtils.AddType
|
||||
#AnsibleRequires -PowerShell ..module_utils.PwshSigned
|
||||
|
||||
@{
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
builtin_csharp = $null -ne ('Ansible.Basic.AnsibleModule' -as [type])
|
||||
builtin_pwsh = [bool](Get-Command Add-CSharpType -ErrorAction SilentlyContinue)
|
||||
collection_csharp = $null -ne ('ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass' -as [type])
|
||||
collection_pwsh = [bool](Get-Command Test-PwshSigned -ErrorAction SilentlyContinue)
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,9 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -PowerShell ..module_utils.PwshUnsigned
|
||||
|
||||
@{
|
||||
changed = $false
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
res = Test-PwshUnsigned
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,9 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
test = 'unsupported'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,5 @@
|
||||
@{
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $args[0]
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,5 @@
|
||||
@{
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $args[0]
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,7 @@
|
||||
- name: run signed script
|
||||
script: signed.ps1 café
|
||||
register: signed_res
|
||||
|
||||
- name: run unsigned script
|
||||
script: unsigned.ps1 café
|
||||
register: unsigned_res
|
||||
@ -0,0 +1,10 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
test = 'ns.invalid_manifest.module'
|
||||
language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
whoami = [Environment]::UserName
|
||||
ünicode = $complex_args.input
|
||||
} | ConvertTo-Json
|
||||
@ -0,0 +1,15 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||
#AnsibleRequires -CSharpUtil ansible_collections.ns.col.plugins.module_utils.CSharpSigned
|
||||
#AnsibleRequires -PowerShell ansible_collections.ns.col.plugins.module_utils.PwshSigned
|
||||
|
||||
# Tests signed util in another trusted collection works
|
||||
|
||||
$module = [Ansible.Basic.AnsibleModule]::Create($args, @{ options = @{} })
|
||||
|
||||
$module.Result.language_mode = $ExecutionContext.SessionState.LanguageMode.ToString()
|
||||
$module.Result.csharp_util = [ansible_collections.ns.col.plugins.module_utils.CSharpSigned.TestClass]::TestMethod("value")
|
||||
$module.Result.powershell_util = Test-PwshSigned
|
||||
|
||||
$module.ExitJson()
|
||||
@ -0,0 +1,15 @@
|
||||
- name: create manifest file
|
||||
ansible.builtin.template:
|
||||
src: '{{ manifest_file }}'
|
||||
dest: '{{ local_tmp_dir }}/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1'
|
||||
delegate_to: localhost
|
||||
|
||||
- name: sign manifest file
|
||||
ansible.builtin.script: >-
|
||||
Set-ManifestSignature.ps1
|
||||
-Path {{ local_tmp_dir ~ "/ansible_collections/ns/invalid_manifest/meta/powershell_signatures.psd1" | quote }}
|
||||
-CertPath {{ local_tmp_dir ~ "/" ~ (cert_name | default("wdac-signing")) ~ ".pfx" | quote }}
|
||||
-CertPass {{ cert_pw | quote }}
|
||||
environment:
|
||||
NO_COLOR: '1'
|
||||
delegate_to: localhost
|
||||
@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# 0.5.0 fixed BOM-less encoding issues with Unicode
|
||||
#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' }
|
||||
|
||||
using namespace System.Collections.Generic
|
||||
using namespace System.IO
|
||||
using namespace System.Management.Automation
|
||||
using namespace System.Management.Automation.Language
|
||||
using namespace System.Security.Cryptography.X509Certificates
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$CollectionPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$CertPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$UntrustedCertPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$CertPass
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Function New-AnsiblePowerShellSignature {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates and signed Ansible content for App Control/WDAC.
|
||||
|
||||
.DESCRIPTION
|
||||
This function will generate the powershell_signatures.psd1 manifest and sign
|
||||
it. The manifest file includes all PowerShell/C# module_utils and
|
||||
PowerShell modules in the collection(s) specified. It will also create the
|
||||
'*.authenticode' signature file for the exec_wrapper.ps1 used inside
|
||||
Ansible itself.
|
||||
|
||||
.PARAMETER Certificate
|
||||
The certificate to use for signing the content.
|
||||
|
||||
.PARAMETER Collection
|
||||
The collection(s) to sign. This is set to ansible.builtin by default but
|
||||
can be overriden to include other collections like ansible.windows.
|
||||
|
||||
.PARAMETER Skip
|
||||
A list of plugins to skip by the fully qualified name. Plugins skipped will
|
||||
not be included in the signed manifest. This means that modules will be run
|
||||
in CLM mode and module_utils will be skipped entirely.
|
||||
|
||||
The values in the list should be the fully qualified name of the plugin as
|
||||
referenced in Ansible. The value can also optionally include the extension
|
||||
of the file if the FQN is ambigious, e.g. collection util that has both a
|
||||
PowerShell and C# util of the same name.
|
||||
|
||||
Here are some examples for the various content types:
|
||||
|
||||
# Ansible Builtin Modules
|
||||
'ansible.builtin.module_name'
|
||||
|
||||
# Ansible Builtin ModuleUtil
|
||||
'Ansible.ModuleUtils.PowerShellUtil'
|
||||
'Ansible.CSharpUtil'
|
||||
|
||||
# Collection Modules
|
||||
'namespace.name.module_name'
|
||||
|
||||
# Collection ModuleUtils
|
||||
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil'
|
||||
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil.psm1'
|
||||
|
||||
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil'
|
||||
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil.cs'
|
||||
|
||||
.PARAMETER Unsupported
|
||||
A list of plugins to be marked as unsupported in the manifest and will
|
||||
error when being run. List -Skip, the values here are the fully qualified
|
||||
name of the plugin as referenced in Ansible.
|
||||
|
||||
.PARAMETER TimeStampServer
|
||||
Optional authenticode timestamp server to use when signing the content.
|
||||
|
||||
.EXAMPLE
|
||||
Signs just the content included in Ansible.
|
||||
|
||||
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||
New-AnsiblePowerShellSignature -Certificate $cert
|
||||
|
||||
.EXAMPLE
|
||||
Signs just the content include in Ansible and the ansible.windows collection
|
||||
|
||||
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||
New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.builtin, ansible.windows
|
||||
|
||||
.EXAMPLE
|
||||
Signs just the content in the ansible.windows collection
|
||||
|
||||
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||
New-AnsiblePowerShellSignature -Certificate $cert -Collection ansible.windows
|
||||
|
||||
.EXAMPLE
|
||||
Signs content but skips the specified modules and module_utils
|
||||
$skip = @(
|
||||
# Skips the module specified
|
||||
'namespace.name.module'
|
||||
|
||||
# Skips the module_utils specified
|
||||
'ansible_collections.namespace.name.plugins.module_utils.PowerShellUtil'
|
||||
'ansible_collections.namespace.name.plugins.module_utils.CSharpUtil'
|
||||
|
||||
# Skips signing the file specified
|
||||
'ansible_collections.namespace.name.plugins.plugin_utils.powershell.file.ps1'
|
||||
)
|
||||
$cert = [X509Certificate2]::new("wdac-cert.pfx", "password")
|
||||
New-AnsiblePowerShellSignature -Certificate $cert -Collection namespace.name -Skip $skip
|
||||
|
||||
.NOTES
|
||||
This function requires Ansible to be installed and available in the PATH so
|
||||
it can find the Ansible installation and collection paths.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(
|
||||
Mandatory
|
||||
)]
|
||||
[X509Certificate2]
|
||||
$Certificate,
|
||||
|
||||
[Parameter(
|
||||
ValueFromPipeline,
|
||||
ValueFromPipelineByPropertyName
|
||||
)]
|
||||
[string[]]
|
||||
$Collection = "ansible.builtin",
|
||||
|
||||
[Parameter(
|
||||
ValueFromPipelineByPropertyName
|
||||
)]
|
||||
[string[]]
|
||||
$Skip = @(),
|
||||
|
||||
[Parameter(
|
||||
ValueFromPipelineByPropertyName
|
||||
)]
|
||||
[string[]]
|
||||
$Unsupported = @(),
|
||||
|
||||
[Parameter()]
|
||||
[string]
|
||||
$TimeStampServer
|
||||
)
|
||||
|
||||
begin {
|
||||
Write-Verbose "Attempting to get ansible-config dump"
|
||||
$configRaw = ansible-config dump --format json --type base 2>&1
|
||||
if ($LASTEXITCODE) {
|
||||
$err = [ErrorRecord]::new(
|
||||
[Exception]::new("Failed to get Ansible configuration, RC: ${LASTEXITCODE} - $configRaw"),
|
||||
'FailedToGetAnsibleConfiguration',
|
||||
[ErrorCategory]::NotSpecified,
|
||||
$null)
|
||||
$PSCmdlet.ThrowTerminatingError($err)
|
||||
}
|
||||
|
||||
$config = $configRaw | ConvertFrom-Json
|
||||
$collectionsPaths = @($config | Where-Object name -EQ 'COLLECTIONS_PATHS' | ForEach-Object value)
|
||||
Write-Verbose "Collections paths to be searched: [$($collectionsPaths -join ":")]"
|
||||
|
||||
$signParams = @{
|
||||
Certificate = $Certificate
|
||||
HashAlgorithm = 'SHA256'
|
||||
}
|
||||
if ($TimeStampServer) {
|
||||
$signParams.TimeStampServer = $TimeStampServer
|
||||
}
|
||||
|
||||
$checked = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
Function New-HashEntry {
|
||||
[OutputType([PSObject])]
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[FileInfo]
|
||||
$File,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[AllowEmptyString()]
|
||||
[string]
|
||||
$PluginBase,
|
||||
|
||||
[Parameter()]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]
|
||||
$Unsupported = @(),
|
||||
|
||||
[Parameter()]
|
||||
[AllowEmptyCollection()]
|
||||
[string[]]
|
||||
$Skip = @()
|
||||
)
|
||||
|
||||
process {
|
||||
$nameWithoutExt = [string]::IsNullOrEmpty($PluginBase) ? $File.BaseName : "$PluginBase.$($File.BaseName)"
|
||||
$nameWithExt = "$nameWithoutExt$($File.Extension)"
|
||||
|
||||
$mode = 'Trusted'
|
||||
if ($nameWithoutExt -in $Skip -or $nameWithExt -in $Skip) {
|
||||
Write-Verbose "Skipping plugin '$nameWithExt' as it is in the supplied skip list"
|
||||
return
|
||||
}
|
||||
elseif ($nameWithoutExt -in $Unsupported -or $nameWithExt -in $Unsupported) {
|
||||
Write-Verbose "Marking plugin '$nameWithExt' as unsupported as it is in the unsupported list"
|
||||
$mode = 'Unsupported'
|
||||
}
|
||||
|
||||
Write-Verbose "Hashing plugin '$nameWithExt'"
|
||||
$hash = Get-FileHash -LiteralPath $File.FullName -Algorithm SHA256
|
||||
[PSCustomObject]@{
|
||||
Name = $nameWithExt
|
||||
Hash = $hash.Hash
|
||||
Mode = $mode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process {
|
||||
$newHashParams = @{
|
||||
Skip = $Skip
|
||||
Unsupported = $Unsupported
|
||||
}
|
||||
|
||||
foreach ($c in $Collection) {
|
||||
try {
|
||||
if (-not $checked.Add($c)) {
|
||||
Write-Verbose "Skipping already processed collection $c"
|
||||
continue
|
||||
}
|
||||
|
||||
$metaPath = $null
|
||||
$pathsToSign = [List[FileInfo]]::new()
|
||||
$hashedPaths = [List[PSObject]]::new()
|
||||
|
||||
if ($c -eq 'ansible.builtin') {
|
||||
Write-Verbose "Attempting to get Ansible installation path"
|
||||
$ansiblePath = python -c "import ansible; print(ansible.__file__)" 2>&1
|
||||
if ($LASTEXITCODE) {
|
||||
throw "Failed to find Ansible installation path, RC: ${LASTEXITCODE} - $ansiblePath"
|
||||
}
|
||||
|
||||
$ansibleBase = Split-Path -Path $ansiblePath -Parent
|
||||
$metaPath = [Path]::Combine($ansibleBase, 'config')
|
||||
|
||||
$execWrapper = Get-Item -LiteralPath ([Path]::Combine($ansibleBase, 'executor', 'powershell', 'exec_wrapper.ps1'))
|
||||
$pathsToSign.Add($execWrapper)
|
||||
|
||||
$ansiblePwshContent = [PSObject[]]@(
|
||||
# These are needed for Ansible and cannot be skipped
|
||||
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'executor', 'powershell', '*.ps1')) -Exclude "bootstrap_wrapper.ps1" |
|
||||
New-HashEntry -PluginBase "ansible.executor.powershell"
|
||||
|
||||
# Builtin utils are special where the filename is their FQN
|
||||
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'csharp', '*.cs')) |
|
||||
New-HashEntry -PluginBase "" @newHashParams
|
||||
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'module_utils', 'powershell', '*.psm1')) |
|
||||
New-HashEntry -PluginBase "" @newHashParams
|
||||
|
||||
Get-ChildItem -Path ([Path]::Combine($ansibleBase, 'modules', '*.ps1')) |
|
||||
New-HashEntry -PluginBase $c @newHashParams
|
||||
)
|
||||
$hashedPaths.AddRange($ansiblePwshContent)
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Attempting to get collection path for $c"
|
||||
$namespace, $name, $remaining = $c.ToLowerInvariant() -split '\.'
|
||||
if (-not $name -or $remaining) {
|
||||
throw "Invalid collection name '$c', must be in the format 'namespace.name'"
|
||||
}
|
||||
|
||||
$foundPath = $null
|
||||
foreach ($path in $collectionsPaths) {
|
||||
$collectionPath = [Path]::Combine($path, 'ansible_collections', $namespace, $name)
|
||||
|
||||
Write-Verbose "Checking if collection $c exists in '$collectionPath'"
|
||||
if (Test-Path -LiteralPath $collectionPath) {
|
||||
$foundPath = $collectionPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundPath) {
|
||||
throw "Failed to find collection path for $c"
|
||||
}
|
||||
|
||||
Write-Verbose "Using collection path '$foundPath' for $c"
|
||||
|
||||
$metaPath = [Path]::Combine($foundPath, 'meta')
|
||||
|
||||
$collectionPwshContent = [PSObject[]]@(
|
||||
$utilPath = [Path]::Combine($foundPath, 'plugins', 'module_utils')
|
||||
if (Test-Path -LiteralPath $utilPath) {
|
||||
Get-ChildItem -LiteralPath $utilPath | Where-Object Extension -In '.cs', '.psm1' |
|
||||
New-HashEntry -PluginBase "ansible_collections.$c.plugins.module_utils" @newHashParams
|
||||
}
|
||||
|
||||
$modulePath = [Path]::Combine($foundPath, 'plugins', 'modules')
|
||||
if (Test-Path -LiteralPath $modulePath) {
|
||||
Get-ChildItem -LiteralPath $modulePath | Where-Object Extension -EQ '.ps1' |
|
||||
New-HashEntry -PluginBase $c @newHashParams
|
||||
}
|
||||
)
|
||||
$hashedPaths.AddRange($collectionPwshContent)
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $metaPath)) {
|
||||
Write-Verbose "Creating meta path '$metaPath'"
|
||||
New-Item -Path $metaPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$manifest = @(
|
||||
'@{'
|
||||
' Version = 1'
|
||||
' HashList = @('
|
||||
foreach ($content in $hashedPaths) {
|
||||
# To avoid encoding problems with Authenticode and non-ASCII
|
||||
# characters, we escape them as Unicode code points. We also
|
||||
# escape some ASCII control characters that can cause escaping
|
||||
# problems like newlines.
|
||||
$escapedName = [Regex]::Replace(
|
||||
$content.Name,
|
||||
'([^\u0020-\u007F])',
|
||||
{ '\u{0:x4}' -f ([uint16][char]$args[0].Value) })
|
||||
|
||||
$escapedHash = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Hash)
|
||||
$escapedMode = [CodeGeneration]::EscapeSingleQuotedStringContent($content.Mode)
|
||||
" # $escapedName"
|
||||
" @{"
|
||||
" Hash = '$escapedHash'"
|
||||
" Mode = '$escapedMode'"
|
||||
" }"
|
||||
}
|
||||
' )'
|
||||
'}'
|
||||
) -join "`n"
|
||||
$manifestPath = [Path]::Combine($metaPath, 'powershell_signatures.psd1')
|
||||
Write-Verbose "Creating and signing manifest for $c at '$manifestPath'"
|
||||
Set-Content -LiteralPath $manifestPath -Value $manifest -NoNewline
|
||||
|
||||
Set-OpenAuthenticodeSignature -LiteralPath $manifestPath @signParams
|
||||
|
||||
$pathsToSign | ForEach-Object -Process {
|
||||
$tempPath = Join-Path $_.DirectoryName "$($_.BaseName)_tmp.ps1"
|
||||
$_ | Copy-Item -Destination $tempPath -Force
|
||||
|
||||
try {
|
||||
Write-Verbose "Signing script '$($_.FullName)'"
|
||||
Set-OpenAuthenticodeSignature -LiteralPath $tempPath @signParams
|
||||
|
||||
$signedContent = Get-Content -LiteralPath $tempPath -Raw
|
||||
$sigIndex = $signedContent.LastIndexOf("`r`n# SIG # Begin signature block`r`n")
|
||||
if ($sigIndex -eq -1) {
|
||||
throw "Failed to find signature block in $($_.FullName)"
|
||||
}
|
||||
|
||||
# Ignore the first and last \r\n when extracting the signature
|
||||
$sigIndex += 2
|
||||
$signature = $signedContent.Substring($sigIndex, $signedContent.Length - $sigIndex - 2)
|
||||
$sigPath = Join-Path $_.DirectoryName "$($_.Name).authenticode"
|
||||
|
||||
Write-Verbose "Creating signature file at '$sigPath'"
|
||||
Set-Content -LiteralPath $sigPath -Value $signature -NoNewline
|
||||
}
|
||||
finally {
|
||||
$tempPath | Remove-Item -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$_.ErrorDetails = "Failed to process collection ${c}: $_"
|
||||
$PSCmdlet.WriteError($_)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cert = [X509Certificate2]::new($CertPath, $CertPass)
|
||||
$untrustedCert = [X509Certificate2]::new($UntrustedCertPath, $CertPass)
|
||||
|
||||
$sigParams = @{
|
||||
Certificate = $cert
|
||||
Collection = 'ansible.builtin', 'ansible.windows', 'ns.col', 'ns.module_util_ref'
|
||||
Skip = @(
|
||||
'ns.col.skipped'
|
||||
'ns.col.inline_signed'
|
||||
'ns.col.inline_signed_not_trusted'
|
||||
'ns.col.unsigned_module_with_util'
|
||||
'ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned'
|
||||
'ansible_collections.ns.col.plugins.module_utils.PwshUnsigned'
|
||||
)
|
||||
Unsupported = 'ns.col.unsupported'
|
||||
}
|
||||
New-AnsiblePowerShellSignature @sigParams
|
||||
|
||||
@(
|
||||
"$CollectionPath/plugins/modules/inline_signed.ps1"
|
||||
"$CollectionPath/roles/app_control_script/files/signed.ps1"
|
||||
) | Set-OpenAuthenticodeSignature -Certificate $cert -HashAlgorithm SHA256
|
||||
|
||||
@(
|
||||
"$CollectionPath/plugins/modules/inline_signed_not_trusted.ps1"
|
||||
) | Set-OpenAuthenticodeSignature -Certificate $untrustedCert -HashAlgorithm SHA256
|
||||
@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# 0.5.0 fixed BOM-less encoding issues with Unicode
|
||||
#Requires -Modules @{ ModuleName = 'OpenAuthenticode'; ModuleVersion = '0.5.0' }
|
||||
|
||||
using namespace System.Security.Cryptography.X509Certificates
|
||||
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$Path,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$CertPath,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$CertPass
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$cert = [X509Certificate2]::new($CertPath, $CertPass)
|
||||
Set-OpenAuthenticodeSignature -FilePath $Path -Certificate $cert -HashAlgorithm SHA256
|
||||
@ -0,0 +1,146 @@
|
||||
- name: run App Control tests on Windows
|
||||
hosts: windows
|
||||
gather_facts: false
|
||||
collections:
|
||||
# This is important so that the temp ansible.windows is chosen over the ansible-test path
|
||||
# which will not be signed
|
||||
- ansible.windows
|
||||
|
||||
handlers:
|
||||
- name: remove openauthenticode
|
||||
shell: Uninstall-PSResource -Name OpenAuthenticode -Version 0.6.1
|
||||
args:
|
||||
executable: pwsh
|
||||
delegate_to: localhost
|
||||
|
||||
tasks:
|
||||
- name: make sure expected facts are set
|
||||
assert:
|
||||
that:
|
||||
- ansible_install_dir is defined
|
||||
- local_tmp_dir is defined
|
||||
|
||||
- name: get OS version
|
||||
win_shell: (Get-Item -LiteralPath $env:SystemRoot\System32\kernel32.dll).VersionInfo.ProductVersion.ToString()
|
||||
register: os_version
|
||||
|
||||
- name: setup and test block for 2019 and later
|
||||
when:
|
||||
- os_version.stdout | trim is version('10.0.17763', '>=') # 2019+
|
||||
block:
|
||||
- name: get test remote tmp dir
|
||||
import_role:
|
||||
name: ../setup_remote_tmp_dir
|
||||
|
||||
- name: get current user
|
||||
win_shell: '[Environment]::UserName'
|
||||
register: current_user_raw
|
||||
|
||||
- name: set current user fact
|
||||
set_fact:
|
||||
current_user: '{{ current_user_raw.stdout | trim }}'
|
||||
|
||||
- name: setup App Control
|
||||
import_tasks: setup.yml
|
||||
|
||||
- name: run content before enabling App Control
|
||||
import_tasks: test_not_enabled.yml
|
||||
|
||||
- name: enable App Control
|
||||
win_shell: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$tmpPath = '{{ remote_tmp_dir }}'
|
||||
|
||||
$policyPath = Join-Path $tmpPath policy.xml
|
||||
$certPath = Join-Path $tmpPath signing.cer
|
||||
$policyName = 'Ansible_AppControl_Test'
|
||||
|
||||
Copy-Item "$env:windir\schemas\CodeIntegrity\ExamplePolicies\DefaultWindows_Enforced.xml" $policyPath
|
||||
Set-CIPolicyIdInfo -FilePath $policyPath -PolicyName $policyName -PolicyId (New-Guid)
|
||||
Set-CIPolicyVersion -FilePath $policyPath -Version "1.0.0.0"
|
||||
|
||||
Add-SignerRule -FilePath $policyPath -CertificatePath $certPath -User
|
||||
Set-RuleOption -FilePath $policyPath -Option 0 # Enabled:UMCI
|
||||
Set-RuleOption -FilePath $policyPath -Option 3 -Delete # Enabled:Audit Mode
|
||||
Set-RuleOption -FilePath $policyPath -Option 11 -Delete # Disabled:Script Enforcement
|
||||
Set-RuleOption -FilePath $policyPath -Option 19 # Enabled:Dynamic Code Security
|
||||
|
||||
# Using $tmpPath has this step fail
|
||||
$policyBinPath = "$env:windir\System32\CodeIntegrity\SiPolicy.p7b"
|
||||
$null = ConvertFrom-CIPolicy -XmlFilePath $policyPath -BinaryFilePath $policyBinPath
|
||||
|
||||
$ciTool = Get-Command -Name CiTool.exe -ErrorAction SilentlyContinue
|
||||
$policyId = $null
|
||||
if ($ciTool) {
|
||||
$setInfo = & $ciTool --update-policy $policyBinPath *>&1
|
||||
if ($LASTEXITCODE) {
|
||||
throw "citool.exe --update-policy failed ${LASTEXITCODE}: $setInfo"
|
||||
}
|
||||
|
||||
$policyId = & $ciTool --list-policies --json |
|
||||
ConvertFrom-Json |
|
||||
Select-Object -ExpandProperty Policies |
|
||||
Where-Object FriendlyName -eq $policyName |
|
||||
Select-Object -ExpandProperty PolicyID
|
||||
}
|
||||
else {
|
||||
$rc = Invoke-CimMethod -Namespace root\Microsoft\Windows\CI -ClassName PS_UpdateAndCompareCIPolicy -MethodName Update -Arguments @{
|
||||
FilePath = $policyBinPath
|
||||
}
|
||||
if ($rc.ReturnValue) {
|
||||
throw "PS_UpdateAndCompareCIPolicy Update failed $($rc.ReturnValue)"
|
||||
}
|
||||
}
|
||||
|
||||
@{
|
||||
policy_id = $policyId
|
||||
path = $policyBinPath
|
||||
} | ConvertTo-Json
|
||||
register: policy_info_raw
|
||||
|
||||
- name: set policy info fact
|
||||
set_fact:
|
||||
policy_info: '{{ policy_info_raw.stdout | from_json }}'
|
||||
|
||||
- name: run content after enabling App Control
|
||||
import_tasks: test_enabled.yml
|
||||
|
||||
- name: run invalid manifest tests
|
||||
import_tasks: test_manifest.yml
|
||||
|
||||
always:
|
||||
- name: disable policy through CiTool if present
|
||||
win_shell: CiTool.exe --remove-policy {{ policy_info.policy_id }}
|
||||
when:
|
||||
- policy_info is defined
|
||||
- policy_info.policy_id is truthy
|
||||
|
||||
- name: remove App Control policy
|
||||
win_file:
|
||||
path: '{{ policy_info.path }}'
|
||||
state: absent
|
||||
register: policy_removal
|
||||
when:
|
||||
- policy_info is defined
|
||||
|
||||
- name: reboot after removing policy file
|
||||
win_reboot:
|
||||
when: policy_removal is changed
|
||||
|
||||
- name: remove certificates
|
||||
win_shell: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Remove-Item -LiteralPath 'Cert:\LocalMachine\Root\{{ cert_info.ca_thumbprint }}' -Force
|
||||
Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPublisher\{{ cert_info.thumbprint }}' -Force
|
||||
when: cert_info is defined
|
||||
|
||||
- name: remove signed Ansible content
|
||||
file:
|
||||
path: '{{ ansible_install_dir }}/{{ item }}'
|
||||
state: absent
|
||||
loop:
|
||||
- config/powershell_signatures.psd1
|
||||
- executor/powershell/exec_wrapper.ps1.authenticode
|
||||
delegate_to: localhost
|
||||
@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if ! command -V pwsh; then
|
||||
echo "skipping test since pwsh is not available"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
source ../collection/setup.sh
|
||||
|
||||
set -x
|
||||
|
||||
ANSIBLE_DIR="$( python -c "import pathlib, ansible; print(pathlib.Path(ansible.__file__).parent)" )"
|
||||
|
||||
ANSIBLE_COLLECTIONS_PATH="${WORK_DIR}" ANSIBLE_DISPLAY_TRACEBACK=error \
|
||||
ansible-playbook "${TEST_DIR}/main.yml" \
|
||||
--inventory "${TEST_DIR}/../../inventory.winrm" \
|
||||
--extra-vars "local_tmp_dir=${WORK_DIR} ansible_install_dir=${ANSIBLE_DIR}" \
|
||||
"${@}"
|
||||
@ -0,0 +1,116 @@
|
||||
- name: setup test facts
|
||||
set_fact:
|
||||
cert_pw: "{{ 'password123!' + lookup('password', '/dev/null chars=ascii_letters,digits length=8') }}"
|
||||
|
||||
- name: setup WDAC certificates
|
||||
win_shell: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$testPrefix = 'Ansible-WDAC'
|
||||
$certPassword = ConvertTo-SecureString -String '{{ cert_pw }}' -Force -AsPlainText
|
||||
$remoteTmpDir = '{{ remote_tmp_dir }}'
|
||||
|
||||
$enhancedKeyUsage = [Security.Cryptography.OidCollection]::new()
|
||||
$null = $enhancedKeyUsage.Add('1.3.6.1.5.5.7.3.3') # Code Signing
|
||||
$caParams = @{
|
||||
Extension = @(
|
||||
[Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true),
|
||||
[Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new('KeyCertSign', $false),
|
||||
[Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension ]::new($enhancedKeyUsage, $false)
|
||||
)
|
||||
CertStoreLocation = 'Cert:\CurrentUser\My'
|
||||
NotAfter = (Get-Date).AddDays(1)
|
||||
Type = 'Custom'
|
||||
}
|
||||
$ca = New-SelfSignedCertificate @caParams -Subject "CN=$testPrefix-Root"
|
||||
|
||||
$certParams = @{
|
||||
CertStoreLocation = 'Cert:\CurrentUser\My'
|
||||
KeyUsage = 'DigitalSignature'
|
||||
TextExtension = @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
|
||||
Type = 'Custom'
|
||||
}
|
||||
$cert = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Signed" -Signer $ca
|
||||
$null = $cert | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\signing.pfx"
|
||||
$cert.Export('Cert') | Set-Content -LiteralPath "$remoteTmpDir\signing.cer" -Encoding Byte
|
||||
|
||||
$certUntrusted = New-SelfSignedCertificate @certParams -Subject "CN=$testPrefix-Untrusted"
|
||||
$null = $certUntrusted | Export-PfxCertificate -Password $certPassword -FilePath "$remoteTmpDir\untrusted.pfx"
|
||||
|
||||
$caWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($ca.Export('Cert'))
|
||||
$certWithoutKey = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.Export('Cert'))
|
||||
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($ca.Thumbprint)" -DeleteKey -Force
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -DeleteKey -Force
|
||||
Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($certUntrusted.Thumbprint)" -DeleteKey -Force
|
||||
|
||||
$root = Get-Item Cert:\LocalMachine\Root
|
||||
$root.Open('ReadWrite')
|
||||
$root.Add($caWithoutKey)
|
||||
$root.Dispose()
|
||||
|
||||
$trustedPublisher = Get-Item Cert:\LocalMachine\TrustedPublisher
|
||||
$trustedPublisher.Open('ReadWrite')
|
||||
$trustedPublisher.Add($certWithoutKey)
|
||||
$trustedPublisher.Dispose()
|
||||
|
||||
@{
|
||||
ca_thumbprint = $caWithoutKey.Thumbprint
|
||||
thumbprint = $certWithoutKey.Thumbprint
|
||||
untrusted_thumbprint = $certUntrusted.Thumbprint
|
||||
} | ConvertTo-Json
|
||||
register: cert_info_raw
|
||||
become: true
|
||||
become_method: runas
|
||||
vars:
|
||||
ansible_become_user: '{{ ansible_user }}'
|
||||
ansible_become_pass: '{{ ansible_password | default(ansible_test_connection_password) }}'
|
||||
|
||||
- name: parse raw cert_info
|
||||
set_fact:
|
||||
cert_info: "{{ cert_info_raw.stdout | from_json }}"
|
||||
|
||||
- name: fetch signing certificates
|
||||
fetch:
|
||||
src: '{{ remote_tmp_dir }}\{{ item }}.pfx'
|
||||
dest: '{{ local_tmp_dir }}/wdac-{{ item }}.pfx'
|
||||
flat: yes
|
||||
loop:
|
||||
- signing
|
||||
- untrusted
|
||||
|
||||
- name: install OpenAuthenticode
|
||||
shell: |
|
||||
if (-not (Get-Module -Name OpenAuthenticode -ListAvailable | Where-Object Version -ge '0.5.0')) {
|
||||
$url = 'https://ansible-ci-files.s3.us-east-1.amazonaws.com/test/integration/targets/win_app_control/openauthenticode.0.6.1.nupkg'
|
||||
Invoke-WebRequest -Uri $url -OutFile '{{ local_tmp_dir }}/openauthenticode.0.6.1.nupkg'
|
||||
|
||||
Register-PSResourceRepository -Name AnsibleTemp -Trusted -Uri '{{ local_tmp_dir }}'
|
||||
try {
|
||||
Install-PSResource -Name OpenAuthenticode -Repository AnsibleTemp
|
||||
} finally {
|
||||
Unregister-PSResourceRepository -Name AnsibleTemp
|
||||
}
|
||||
|
||||
$true
|
||||
} else {
|
||||
$false
|
||||
}
|
||||
args:
|
||||
executable: pwsh
|
||||
register: open_auth_install
|
||||
changed_when: open_auth_install.stdout | bool
|
||||
notify: remove openauthenticode
|
||||
delegate_to: localhost
|
||||
|
||||
- name: sign Ansible content
|
||||
script: >-
|
||||
New-AnsiblePowerShellSignature.ps1
|
||||
-CollectionPath {{ local_tmp_dir ~ "/ansible_collections/ns/col" | quote }}
|
||||
-CertPath {{ local_tmp_dir ~ "/wdac-signing.pfx" | quote }}
|
||||
-UntrustedCertPath {{ local_tmp_dir ~ "/wdac-untrusted.pfx" | quote }}
|
||||
-CertPass {{ cert_pw | quote }}
|
||||
-Verbose
|
||||
environment:
|
||||
NO_COLOR: '1'
|
||||
delegate_to: localhost
|
||||
@ -0,0 +1,9 @@
|
||||
@{
|
||||
Version = 2
|
||||
HashList = @(
|
||||
@{
|
||||
Hash = '{{ module_hash }}'
|
||||
Mode = 'Trusted'
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
'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'
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
- name: run signed module
|
||||
ns.col.signed:
|
||||
input: café
|
||||
register: signed_res
|
||||
|
||||
- name: assert run signed module
|
||||
assert:
|
||||
that:
|
||||
- signed_res.language_mode == 'FullLanguage'
|
||||
- signed_res.test == 'signed'
|
||||
- signed_res.whoami == current_user
|
||||
- signed_res.ünicode == 'café'
|
||||
|
||||
- name: run inline signed module
|
||||
ns.col.inline_signed:
|
||||
input: café
|
||||
register: inline_signed_res
|
||||
|
||||
- name: assert run inline signed module
|
||||
assert:
|
||||
that:
|
||||
- inline_signed_res.language_mode == 'FullLanguage'
|
||||
- inline_signed_res.test == 'inline_signed'
|
||||
- inline_signed_res.whoami == current_user
|
||||
- inline_signed_res.ünicode == 'café'
|
||||
|
||||
- name: run inline signed module that is not trusted
|
||||
ns.col.inline_signed_not_trusted:
|
||||
input: café
|
||||
register: inline_signed_not_trusted_res
|
||||
|
||||
- name: assert run inline signed module
|
||||
assert:
|
||||
that:
|
||||
- inline_signed_not_trusted_res.language_mode == 'ConstrainedLanguage'
|
||||
- inline_signed_not_trusted_res.test == 'inline_signed_not_trusted'
|
||||
- inline_signed_not_trusted_res.whoami == current_user
|
||||
- inline_signed_not_trusted_res.ünicode == 'café'
|
||||
|
||||
- name: run signed module to test exec wrapper scope
|
||||
ns.col.scope:
|
||||
register: scoped_res
|
||||
|
||||
- name: assert run signed module to test exec wrapper scope
|
||||
assert:
|
||||
that:
|
||||
- scoped_res.missing_using_namespace == True
|
||||
- scoped_res.module_using_namespace == 'System.Management.Automation.Language.Parser'
|
||||
- scoped_res.script_var == 'foo'
|
||||
|
||||
- name: run module marked as skipped
|
||||
ns.col.skipped:
|
||||
input: café
|
||||
register: skipped_res
|
||||
|
||||
- name: assert run module marked as skipped
|
||||
assert:
|
||||
that:
|
||||
- skipped_res.language_mode == 'ConstrainedLanguage'
|
||||
- skipped_res.test == 'skipped'
|
||||
- skipped_res.whoami == current_user
|
||||
- skipped_res.ünicode == 'café'
|
||||
|
||||
- name: run module marked as skipped with a failure
|
||||
ns.col.skipped:
|
||||
should_fail: true
|
||||
register: skipped_fail_res
|
||||
ignore_errors: true
|
||||
|
||||
- name: assert run module marked as skipped with a failure
|
||||
assert:
|
||||
that:
|
||||
- skipped_fail_res is failed
|
||||
- >-
|
||||
skipped_fail_res.msg == "Unhandled exception while executing module: exception here"
|
||||
- skipped_fail_res.exception is search("At .*\\\\ansible_collections\.ns\.col\.plugins\.modules\.skipped-.*\.ps1")
|
||||
|
||||
- name: run module marked as unsupported
|
||||
ns.col.unsupported:
|
||||
register: unsupported_res
|
||||
failed_when:
|
||||
- unsupported_res.failed == False
|
||||
- >-
|
||||
unsupported_res.msg is not contains("Provided script for 'ansible_collections.ns.col.plugins.modules.unsupported.ps1' is marked as unsupported in CLM mode.")
|
||||
|
||||
- name: run module with signed utils
|
||||
ns.col.signed_module_util:
|
||||
register: signed_util_res
|
||||
|
||||
- name: assert run module with signed utils
|
||||
assert:
|
||||
that:
|
||||
- signed_util_res.language_mode == 'FullLanguage'
|
||||
- signed_util_res.builtin_powershell_util == 'value'
|
||||
- signed_util_res.csharp_util == 'value'
|
||||
- signed_util_res.powershell_util.language_mode == 'FullLanguage'
|
||||
|
||||
- name: run module with unsigned C# util
|
||||
ns.col.unsigned_csharp_util:
|
||||
register: unsigned_csharp_util_res
|
||||
failed_when:
|
||||
- unsigned_csharp_util_res.failed == False
|
||||
- >-
|
||||
unsigned_csharp_util_res.msg is not contains("C# module util 'ansible_collections.ns.col.plugins.module_utils.CSharpUnsigned.cs' is not trusted and cannot be loaded.")
|
||||
|
||||
- name: run module with unsigned Pwsh util
|
||||
ns.col.unsigned_pwsh_util:
|
||||
register: unsigned_pwsh_util_res
|
||||
failed_when:
|
||||
- unsigned_pwsh_util_res.failed == False
|
||||
- >-
|
||||
unsigned_pwsh_util_res.msg is not contains("PowerShell module util 'ansible_collections.ns.col.plugins.module_utils.PwshUnsigned.cs' is not trusted and cannot be loaded.")
|
||||
|
||||
- name: run unsigned module with utils
|
||||
ns.col.unsigned_module_with_util:
|
||||
register: unsigned_module_with_util_res
|
||||
failed_when:
|
||||
- unsigned_module_with_util_res.failed == False
|
||||
- >-
|
||||
unsigned_module_with_util_res.msg is not contains("Cannot run untrusted PowerShell script 'ns.col.unsigned_with_util.ps1' in ConstrainedLanguage mode with module util imports.")
|
||||
|
||||
- name: run script role
|
||||
import_role:
|
||||
name: ns.col.app_control_script
|
||||
|
||||
- name: assert script role result
|
||||
assert:
|
||||
that:
|
||||
- signed_res.rc == 0
|
||||
- (signed_res.stdout | from_json).language_mode == 'FullLanguage'
|
||||
- (signed_res.stdout | from_json).whoami == current_user
|
||||
- (signed_res.stdout | from_json).ünicode == 'café'
|
||||
- unsigned_res.rc == 0
|
||||
- (unsigned_res.stdout | from_json).language_mode == 'ConstrainedLanguage'
|
||||
- (unsigned_res.stdout | from_json).whoami == current_user
|
||||
- (unsigned_res.stdout | from_json).ünicode == 'café'
|
||||
|
||||
- name: signed module with become
|
||||
ns.col.signed:
|
||||
input: café
|
||||
become: true
|
||||
become_method: runas
|
||||
become_user: SYSTEM
|
||||
register: become_res
|
||||
|
||||
- name: assert run signed module with become
|
||||
assert:
|
||||
that:
|
||||
- become_res.language_mode == 'FullLanguage'
|
||||
- become_res.test == 'signed'
|
||||
- become_res.whoami == 'SYSTEM'
|
||||
- become_res.ünicode == 'café'
|
||||
|
||||
- name: signed module with async
|
||||
ns.col.signed:
|
||||
input: café
|
||||
async: 60
|
||||
poll: 3
|
||||
register: async_res
|
||||
|
||||
- name: assert run signed module with async
|
||||
assert:
|
||||
that:
|
||||
- async_res.language_mode == 'FullLanguage'
|
||||
- async_res.test == 'signed'
|
||||
- async_res.whoami == current_user
|
||||
- async_res.ünicode == 'café'
|
||||
|
||||
- name: copy file
|
||||
win_copy:
|
||||
src: New-AnsiblePowerShellSignature.ps1
|
||||
dest: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
|
||||
register: copy_res
|
||||
|
||||
- name: get remote hash of copied file
|
||||
win_stat:
|
||||
path: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
|
||||
get_checksum: true
|
||||
register: copy_stat
|
||||
|
||||
- name: assert copy file
|
||||
assert:
|
||||
that:
|
||||
- copy_res.checksum == copy_stat.stat.checksum
|
||||
|
||||
- name: fetch file
|
||||
fetch:
|
||||
src: '{{ remote_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
|
||||
dest: '{{ local_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
|
||||
flat: true
|
||||
register: fetch_res
|
||||
|
||||
- name: get local hash of fetch file
|
||||
stat:
|
||||
path: '{{ local_tmp_dir }}/New-AnsiblePowerShellSignature.ps1'
|
||||
get_checksum: true
|
||||
delegate_to: localhost
|
||||
register: fetch_stat
|
||||
|
||||
- name: assert fetch file
|
||||
assert:
|
||||
that:
|
||||
- fetch_res.checksum == copy_stat.stat.checksum
|
||||
- fetch_res.checksum == fetch_stat.stat.checksum
|
||||
|
||||
- name: run signed module with signed module util in another collection
|
||||
ns.module_util_ref.module:
|
||||
register: cross_util_res
|
||||
|
||||
- name: assert run signed module with signed module utils in another collection
|
||||
assert:
|
||||
that:
|
||||
- cross_util_res.language_mode == 'FullLanguage'
|
||||
- cross_util_res.csharp_util == 'value'
|
||||
- "cross_util_res.powershell_util == {'language_mode': 'FullLanguage'}"
|
||||
@ -0,0 +1,154 @@
|
||||
# These tests verify various failure conditions that will invalidate a signed manifest
|
||||
|
||||
- name: get hash of collection module
|
||||
ansible.builtin.stat:
|
||||
path: '{{ local_tmp_dir }}/ansible_collections/ns/invalid_manifest/plugins/modules/module.ps1'
|
||||
get_checksum: true
|
||||
checksum_algorithm: sha256
|
||||
delegate_to: localhost
|
||||
register: module_hash_raw
|
||||
|
||||
- name: set module hash var
|
||||
ansible.builtin.set_fact:
|
||||
module_hash: '{{ module_hash_raw.stat.checksum | upper }}'
|
||||
|
||||
- name: create manifest with untrusted signature
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_ok.psd1
|
||||
cert_name: wdac-untrusted
|
||||
|
||||
- name: run module with untrusted signed manifest
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': script is not signed or not trusted to run.")
|
||||
|
||||
- name: create manifest with no Hashtable
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_no_hashtable.psd1
|
||||
|
||||
- name: run module with no Hashtable
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting a single hashtable in the signed manifest.")
|
||||
|
||||
- name: create manifest with no Version
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_no_version.psd1
|
||||
|
||||
- name: run module with no Version
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain 'Version' key.")
|
||||
|
||||
- name: create manifest with invalid Version
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_invalid_version.psd1
|
||||
|
||||
- name: run module with invalid Version
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': unsupported hash list Version 2, expecting 1.")
|
||||
|
||||
- name: create manifest with no HashList
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_no_hashlist.psd1
|
||||
|
||||
- name: run module with no HashList
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain 'HashList' key.")
|
||||
|
||||
- name: create manifest with no Hash subkey
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_no_hash_subkey.psd1
|
||||
|
||||
- name: run module with no Hash subkey
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings.")
|
||||
|
||||
- name: create manifest with invalid Hash subkey value
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_invalid_hash_subkey.psd1
|
||||
|
||||
- name: run module with invalid Hash subkey value
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings.")
|
||||
|
||||
- name: create manifest with no Mode subkey
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_no_mode_subkey.psd1
|
||||
|
||||
- name: run module with no Mode subkey
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list entry for " ~ module_hash ~ " to contain a mode of 'Trusted' or 'Unsupported' but got ''.")
|
||||
|
||||
- name: create manfiest with invalid Mode subkey value
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_invalid_mode_subkey.psd1
|
||||
|
||||
- name: run module with invalid Mode subkey value
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not contains("Failed to process signed manifest 'ansible_collections.ns.invalid_manifest.meta.powershell_signatures.psd1': expecting hash list entry for " ~ module_hash ~ " to contain a mode of 'Trusted' or 'Unsupported' but got 'Other'.")
|
||||
|
||||
- name: create manifest with unsafe expressions
|
||||
ansible.builtin.import_tasks: create_manifest.yml
|
||||
vars:
|
||||
manifest_file: manifest_v1_unsafe_expression.psd1
|
||||
|
||||
- name: run module with unsafe expressions
|
||||
ns.invalid_manifest.module:
|
||||
input: café
|
||||
register: res
|
||||
failed_when:
|
||||
- res.failed == False
|
||||
- >-
|
||||
res.msg is not search("failure during exec_wrapper: Failed to process signed manifest 'ansible_collections\.ns\.invalid_manifest\.meta\.powershell_signatures.psd1':.*Cannot generate a Windows PowerShell object for a ScriptBlock evaluating dynamic expressions")
|
||||
@ -0,0 +1,148 @@
|
||||
# When App Control is not enabled we expect modules even if signed or unsigned should
|
||||
# run without any changes in FullLanguageMode.
|
||||
|
||||
- name: run signed module
|
||||
ns.col.signed:
|
||||
input: café
|
||||
register: signed_res
|
||||
|
||||
- name: assert run signed module
|
||||
assert:
|
||||
that:
|
||||
- signed_res.language_mode == 'FullLanguage'
|
||||
- signed_res.test == 'signed'
|
||||
- signed_res.whoami == current_user
|
||||
- signed_res.ünicode == 'café'
|
||||
|
||||
- name: run inline signed module
|
||||
ns.col.inline_signed:
|
||||
input: café
|
||||
register: inline_signed_res
|
||||
|
||||
- name: assert run inline signed module
|
||||
assert:
|
||||
that:
|
||||
- inline_signed_res.language_mode == 'FullLanguage'
|
||||
- inline_signed_res.test == 'inline_signed'
|
||||
- inline_signed_res.whoami == current_user
|
||||
- inline_signed_res.ünicode == 'café'
|
||||
|
||||
- name: run inline signed module that is not trusted
|
||||
ns.col.inline_signed_not_trusted:
|
||||
input: café
|
||||
register: inline_signed_not_trusted_res
|
||||
|
||||
- name: assert run inline signed module
|
||||
assert:
|
||||
that:
|
||||
- inline_signed_not_trusted_res.language_mode == 'FullLanguage'
|
||||
- inline_signed_not_trusted_res.test == 'inline_signed_not_trusted'
|
||||
- inline_signed_not_trusted_res.whoami == current_user
|
||||
- inline_signed_not_trusted_res.ünicode == 'café'
|
||||
|
||||
- name: run signed module to test exec wrapper scope
|
||||
ns.col.scope:
|
||||
register: scoped_res
|
||||
|
||||
- name: assert run signed module to test exec wrapper scope
|
||||
assert:
|
||||
that:
|
||||
- scoped_res.missing_using_namespace == True
|
||||
- scoped_res.module_using_namespace == 'System.Management.Automation.Language.Parser'
|
||||
- scoped_res.script_var == 'foo'
|
||||
|
||||
- name: run module marked as skipped
|
||||
ns.col.skipped:
|
||||
input: café
|
||||
register: skipped_res
|
||||
|
||||
- name: assert run module marked as skipped
|
||||
assert:
|
||||
that:
|
||||
- skipped_res.language_mode == 'FullLanguage'
|
||||
- skipped_res.test == 'skipped'
|
||||
- skipped_res.whoami == current_user
|
||||
- skipped_res.ünicode == 'café'
|
||||
|
||||
- name: run module marked as unsupported
|
||||
ns.col.unsupported:
|
||||
register: unsupported_res
|
||||
|
||||
- name: assert run module marked as unsupported
|
||||
assert:
|
||||
that:
|
||||
- unsupported_res.language_mode == 'FullLanguage'
|
||||
- unsupported_res.test == 'unsupported'
|
||||
- unsupported_res.whoami == current_user
|
||||
|
||||
- name: run module with signed utils
|
||||
ns.col.signed_module_util:
|
||||
register: signed_util_res
|
||||
|
||||
- name: assert run module with signed utils
|
||||
assert:
|
||||
that:
|
||||
- signed_util_res.language_mode == 'FullLanguage'
|
||||
- signed_util_res.builtin_powershell_util == 'value'
|
||||
- signed_util_res.csharp_util == 'value'
|
||||
- signed_util_res.powershell_util.language_mode == 'FullLanguage'
|
||||
|
||||
- name: run module with unsigned C# util
|
||||
ns.col.unsigned_csharp_util:
|
||||
register: unsigned_csharp_util_res
|
||||
|
||||
- name: assert run module with unsigned C# util
|
||||
assert:
|
||||
that:
|
||||
- unsigned_csharp_util_res.language_mode == 'FullLanguage'
|
||||
- unsigned_csharp_util_res.res == 'value'
|
||||
|
||||
- name: run module with unsigned Pwsh util
|
||||
ns.col.unsigned_pwsh_util:
|
||||
register: unsigned_pwsh_util_res
|
||||
|
||||
- name: assert run module with unsigned Pwsh util
|
||||
assert:
|
||||
that:
|
||||
- unsigned_pwsh_util_res.language_mode == 'FullLanguage'
|
||||
- unsigned_pwsh_util_res.res.language_mode == 'FullLanguage'
|
||||
|
||||
- name: run unsigned module with utils
|
||||
ns.col.unsigned_module_with_util:
|
||||
register: unsigned_module_with_util_res
|
||||
|
||||
- name: assert run unsigned module with utils
|
||||
assert:
|
||||
that:
|
||||
- unsigned_module_with_util_res.language_mode == 'FullLanguage'
|
||||
- unsigned_module_with_util_res.builtin_csharp == True
|
||||
- unsigned_module_with_util_res.builtin_pwsh == True
|
||||
- unsigned_module_with_util_res.collection_csharp == True
|
||||
- unsigned_module_with_util_res.collection_pwsh == True
|
||||
|
||||
- name: run script role
|
||||
import_role:
|
||||
name: ns.col.app_control_script
|
||||
|
||||
- name: assert script role result
|
||||
assert:
|
||||
that:
|
||||
- signed_res.rc == 0
|
||||
- (signed_res.stdout | from_json).language_mode == 'FullLanguage'
|
||||
- (signed_res.stdout | from_json).whoami == current_user
|
||||
- (signed_res.stdout | from_json).ünicode == 'café'
|
||||
- unsigned_res.rc == 0
|
||||
- (unsigned_res.stdout | from_json).language_mode == 'FullLanguage'
|
||||
- (unsigned_res.stdout | from_json).whoami == current_user
|
||||
- (unsigned_res.stdout | from_json).ünicode == 'café'
|
||||
|
||||
- name: run signed module with signed module util in another collection
|
||||
ns.module_util_ref.module:
|
||||
register: cross_util_res
|
||||
|
||||
- name: assert run signed module with signed module utils in another collection
|
||||
assert:
|
||||
that:
|
||||
- cross_util_res.language_mode == 'FullLanguage'
|
||||
- cross_util_res.csharp_util == 'value'
|
||||
- "cross_util_res.powershell_util == {'language_mode': 'FullLanguage'}"
|
||||
@ -0,0 +1,8 @@
|
||||
#!powershell
|
||||
|
||||
#AnsibleRequires -Wrapper
|
||||
|
||||
@{
|
||||
changed = $false
|
||||
complex_args = $complex_args
|
||||
} | ConvertTo-Json -Depth 99
|
||||
@ -0,0 +1 @@
|
||||
'ünicode'
|
||||
Loading…
Reference in New Issue