mirror of
https://github.com/zebrajr/ansible.git
synced 2025-12-06 00:19:48 +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
|
||||
lib/ansible/_vendor/*
|
||||
!lib/ansible/_vendor/__init__.py
|
||||
# PowerShell signed hashlist
|
||||
lib/ansible/config/powershell_signatures.psd1
|
||||
*.authenticode
|
||||
# test stuff
|
||||
/test/integration/cloud-config-*.*
|
||||
!/test/integration/cloud-config-*.*.template
|
||||
|
|
|
|||
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'
|
||||
b_module_data = b_module_data.replace(REPLACER_WINDOWS, b'#AnsibleRequires -PowerShell Ansible.ModuleUtils.Legacy')
|
||||
elif re.search(b'#Requires -Module', b_module_data, re.IGNORECASE) \
|
||||
or re.search(b'#Requires -Version', b_module_data, re.IGNORECASE)\
|
||||
or re.search(b'#AnsibleRequires -OSVersion', b_module_data, re.IGNORECASE) \
|
||||
or re.search(b'#AnsibleRequires -Powershell', b_module_data, re.IGNORECASE) \
|
||||
or re.search(b'#AnsibleRequires -CSharpUtil', b_module_data, re.IGNORECASE):
|
||||
or re.search(b'#Requires -Version', b_module_data, re.IGNORECASE) \
|
||||
or re.search(b'#AnsibleRequires -(OSVersion|PowerShell|CSharpUtil|Wrapper)', b_module_data, re.IGNORECASE):
|
||||
module_style = 'new'
|
||||
module_substyle = 'powershell'
|
||||
elif REPLACER_JSONARGS in b_module_data:
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ param([ScriptBlock]$ScriptBlock, $Param)
|
|||
& $ScriptBlock.Ast.GetScriptBlock() @Param
|
||||
'@).AddParameters(
|
||||
@{
|
||||
ScriptBlock = $execInfo.ScriptBlock
|
||||
ScriptBlock = $execInfo.ScriptInfo.ScriptBlock
|
||||
Param = $execInfo.Parameters
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ try {
|
|||
}
|
||||
$execWrapper = @{
|
||||
name = 'exec_wrapper-async.ps1'
|
||||
script = $execAction.Script
|
||||
script = $execAction.ScriptInfo.Script
|
||||
params = $execAction.Parameters
|
||||
} | ConvertTo-Json -Compress -Depth 99
|
||||
$asyncInput = "$execWrapper`n`0`0`0`0`n$($execAction.InputData)"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ using namespace System.Collections
|
|||
using namespace System.Diagnostics
|
||||
using namespace System.IO
|
||||
using namespace System.Management.Automation
|
||||
using namespace System.Management.Automation.Security
|
||||
using namespace System.Net
|
||||
using namespace System.Text
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ $executablePath = Join-Path -Path $PSHome -ChildPath $executable
|
|||
$actionInfo = Get-AnsibleExecWrapper -EncodeInputOutput
|
||||
$bootstrapManifest = ConvertTo-Json -InputObject @{
|
||||
n = "exec_wrapper-become-$([Guid]::NewGuid()).ps1"
|
||||
s = $actionInfo.Script
|
||||
s = $actionInfo.ScriptInfo.Script
|
||||
p = $actionInfo.Parameters
|
||||
} -Depth 99 -Compress
|
||||
|
||||
|
|
@ -68,10 +69,27 @@ $m=foreach($i in $input){
|
|||
$m=$m|ConvertFrom-Json
|
||||
$p=@{}
|
||||
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.
|
||||
$command = @(
|
||||
($command -split "\r?\n") |
|
||||
|
|
|
|||
|
|
@ -18,10 +18,32 @@ foreach ($obj in $code.params.PSObject.Properties) {
|
|||
$splat[$obj.Name] = $obj.Value
|
||||
}
|
||||
|
||||
$cmd = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||
$code.script,
|
||||
"$($code.name).ps1", # Name is used in stack traces.
|
||||
[ref]$null,
|
||||
[ref]$null).GetScriptBlock()
|
||||
$filePath = $null
|
||||
try {
|
||||
$cmd = if ($ExecutionContext.SessionState.LanguageMode -eq 'FullLanguage') {
|
||||
# In FLM we can just invoke the code as a scriptblock without touching the
|
||||
# disk.
|
||||
[System.Management.Automation.Language.Parser]::ParseInput(
|
||||
$code.script,
|
||||
"$($code.name).ps1", # Name is used in stack traces.
|
||||
[ref]$null,
|
||||
[ref]$null).GetScriptBlock()
|
||||
}
|
||||
else {
|
||||
# CLM needs to execute code from a file for it to run in FLM when trusted.
|
||||
# Set-Item on 5.1 doesn't have a way to use UTF-8 without a BOM but luckily
|
||||
# New-Item does that by default for both 5.1 and 7. We need to ensure we
|
||||
# use UTF-8 without BOM so the signature is correct.
|
||||
$filePath = Join-Path -Path $env:TEMP -ChildPath "$($code.name)-$(New-Guid).ps1"
|
||||
$null = New-Item -Path $filePath -Value $code.script -ItemType File -Force
|
||||
|
||||
$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.Language
|
||||
using namespace System.Management.Automation.Security
|
||||
using namespace System.Reflection
|
||||
using namespace System.Security.Cryptography
|
||||
using namespace System.Text
|
||||
|
||||
|
|
@ -53,6 +54,10 @@ begin {
|
|||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
if ($PSCommandPath -and (Test-Path -LiteralPath $PSCommandPath)) {
|
||||
Remove-Item -LiteralPath $PSCommandPath -Force
|
||||
}
|
||||
|
||||
# Try and set the console encoding to UTF-8 allowing Ansible to read the
|
||||
# output of the wrapper as UTF-8 bytes.
|
||||
try {
|
||||
|
|
@ -89,6 +94,9 @@ begin {
|
|||
}
|
||||
|
||||
# $Script:AnsibleManifest = @{} # Defined in process/end.
|
||||
$Script:AnsibleShouldConstrain = [SystemPolicy]::GetSystemLockdownPolicy() -eq 'Enforce'
|
||||
$Script:AnsibleTrustedHashList = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$Script:AnsibleUnsupportedHashList = [HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
$Script:AnsibleWrapperWarnings = [List[string]]::new()
|
||||
$Script:AnsibleTempPath = @(
|
||||
# Wrapper defined tmpdir
|
||||
|
|
@ -110,6 +118,8 @@ begin {
|
|||
$false
|
||||
}
|
||||
} | Select-Object -First 1
|
||||
$Script:AnsibleTempScripts = [List[string]]::new()
|
||||
$Script:AnsibleClrFacadeSet = $false
|
||||
|
||||
Function Convert-JsonObject {
|
||||
param(
|
||||
|
|
@ -147,7 +157,11 @@ begin {
|
|||
|
||||
[Parameter()]
|
||||
[switch]
|
||||
$IncludeScriptBlock
|
||||
$IncludeScriptBlock,
|
||||
|
||||
[Parameter()]
|
||||
[switch]
|
||||
$SkipHashCheck
|
||||
)
|
||||
|
||||
if (-not $Script:AnsibleManifest.scripts.Contains($Name)) {
|
||||
|
|
@ -172,11 +186,93 @@ begin {
|
|||
[ref]$null).GetScriptBlock()
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
$outputValue = [PSCustomObject]@{
|
||||
Name = $Name
|
||||
Script = $scriptContents
|
||||
Path = $scriptInfo.path
|
||||
ScriptBlock = $sbk
|
||||
ShouldConstrain = $false
|
||||
}
|
||||
|
||||
if (-not $Script:AnsibleShouldConstrain) {
|
||||
$outputValue
|
||||
return
|
||||
}
|
||||
|
||||
if (-not $SkipHashCheck) {
|
||||
$sha256 = [SHA256]::Create()
|
||||
$scriptHash = [BitConverter]::ToString($sha256.ComputeHash($scriptBytes)).Replace("-", "")
|
||||
$sha256.Dispose()
|
||||
|
||||
if ($Script:AnsibleUnsupportedHashList.Contains($scriptHash)) {
|
||||
$err = [ErrorRecord]::new(
|
||||
[Exception]::new("Provided script for '$Name' is marked as unsupported in CLM mode."),
|
||||
"ScriptUnsupported",
|
||||
[ErrorCategory]::SecurityError,
|
||||
$Name)
|
||||
$PSCmdlet.ThrowTerminatingError($err)
|
||||
}
|
||||
elseif ($Script:AnsibleTrustedHashList.Contains($scriptHash)) {
|
||||
$outputValue
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
# If we have reached here we are running in a locked down environment
|
||||
# and the script is not trusted in the signed hashlists. Check if it
|
||||
# contains the authenticode signature and verify that using PowerShell.
|
||||
# [SystemPolicy]::GetFilePolicyEnforcement(...) is a new API but only
|
||||
# present in Server 2025+ so we need to rely on the known behaviour of
|
||||
# Get-Command to fail with CommandNotFoundException if the script is
|
||||
# not allowed to run.
|
||||
$outputValue.ShouldConstrain = $true
|
||||
if ($scriptContents -like "*`r`n# SIG # Begin signature block`r`n*") {
|
||||
Set-WinPSDefaultFileEncoding
|
||||
|
||||
# If the script is manually signed we need to ensure the signature
|
||||
# is valid and trusted by the OS policy.
|
||||
# We must use '.ps1' so the ExternalScript WDAC check will apply.
|
||||
$tmpFile = [Path]::Combine($Script:AnsibleTempPath, "ansible-tmp-$([Guid]::NewGuid()).ps1")
|
||||
try {
|
||||
[File]::WriteAllBytes($tmpFile, $scriptBytes)
|
||||
$cmd = Get-Command -Name $tmpFile -CommandType ExternalScript -ErrorAction Stop
|
||||
|
||||
# Get-Command caches the file contents after loading which we
|
||||
# use to verify it was not modified before the signature check.
|
||||
$expectedScript = $cmd.OriginalEncoding.GetString($scriptBytes)
|
||||
if ($expectedScript -ne $cmd.ScriptContents) {
|
||||
$err = [ErrorRecord]::new(
|
||||
[Exception]::new("Script has been modified during signature check."),
|
||||
"ScriptModifiedTrusted",
|
||||
[ErrorCategory]::SecurityError,
|
||||
$Name)
|
||||
$PSCmdlet.ThrowTerminatingError($err)
|
||||
}
|
||||
|
||||
$outputValue.ShouldConstrain = $false
|
||||
}
|
||||
catch [CommandNotFoundException] {
|
||||
$null = $null # No-op but satisfies the linter.
|
||||
}
|
||||
finally {
|
||||
if (Test-Path -LiteralPath $tmpFile) {
|
||||
Remove-Item -LiteralPath $tmpFile -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($outputValue.ShouldConstrain -and $IncludeScriptBlock) {
|
||||
# If the script is untrusted and a scriptblock was requested we
|
||||
# error out as the sbk would have run in FLM.
|
||||
$err = [ErrorRecord]::new(
|
||||
[Exception]::new("Provided script for '$Name' is not trusted to run."),
|
||||
"ScriptNotTrusted",
|
||||
[ErrorCategory]::SecurityError,
|
||||
$Name)
|
||||
$PSCmdlet.ThrowTerminatingError($err)
|
||||
}
|
||||
else {
|
||||
$outputValue
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +319,7 @@ begin {
|
|||
$IncludeScriptBlock
|
||||
)
|
||||
|
||||
$sbk = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
|
||||
$scriptInfo = Get-AnsibleScript -Name exec_wrapper.ps1 -IncludeScriptBlock:$IncludeScriptBlock
|
||||
$params = @{
|
||||
# TempPath may contain env vars that change based on the runtime
|
||||
# environment. Ensure we use that and not the $script:AnsibleTempPath
|
||||
|
|
@ -244,8 +340,7 @@ begin {
|
|||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
Script = $sbk.Script
|
||||
ScriptBlock = $sbk.ScriptBlock
|
||||
ScriptInfo = $scriptInfo
|
||||
Parameters = $params
|
||||
InputData = $inputData
|
||||
}
|
||||
|
|
@ -279,11 +374,16 @@ begin {
|
|||
|
||||
$isBasicUtil = $false
|
||||
$csharpModules = foreach ($moduleName in $Name) {
|
||||
(Get-AnsibleScript -Name $moduleName).Script
|
||||
$scriptInfo = Get-AnsibleScript -Name $moduleName
|
||||
|
||||
if ($scriptInfo.ShouldConstrain) {
|
||||
throw "C# module util '$Name' is not trusted and cannot be loaded."
|
||||
}
|
||||
if ($moduleName -eq 'Ansible.Basic.cs') {
|
||||
$isBasicUtil = $true
|
||||
}
|
||||
|
||||
$scriptInfo.Script
|
||||
}
|
||||
|
||||
$fakeModule = [PSCustomObject]@{
|
||||
|
|
@ -303,6 +403,112 @@ begin {
|
|||
}
|
||||
}
|
||||
|
||||
Function Import-SignedHashList {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory, ValueFromPipeline)]
|
||||
[string]
|
||||
$Name
|
||||
)
|
||||
|
||||
process {
|
||||
try {
|
||||
# We skip the hash check to ensure we verify based on the
|
||||
# authenticode signature and not whether it's trusted by an
|
||||
# existing signed hash list.
|
||||
$scriptInfo = Get-AnsibleScript -Name $Name -SkipHashCheck
|
||||
if ($scriptInfo.ShouldConstrain) {
|
||||
throw "script is not signed or not trusted to run."
|
||||
}
|
||||
|
||||
$hashListAst = [Parser]::ParseInput(
|
||||
$scriptInfo.Script,
|
||||
$Name,
|
||||
[ref]$null,
|
||||
[ref]$null)
|
||||
$manifestAst = $hashListAst.Find({ $args[0] -is [HashtableAst] }, $false)
|
||||
if ($null -eq $manifestAst) {
|
||||
throw "expecting a single hashtable in the signed manifest."
|
||||
}
|
||||
|
||||
$out = $manifestAst.SafeGetValue()
|
||||
if (-not $out.Contains('Version')) {
|
||||
throw "expecting hash list to contain 'Version' key."
|
||||
}
|
||||
if ($out.Version -ne 1) {
|
||||
throw "unsupported hash list Version $($out.Version), expecting 1."
|
||||
}
|
||||
|
||||
if (-not $out.Contains('HashList')) {
|
||||
throw "expecting hash list to contain 'HashList' key."
|
||||
}
|
||||
|
||||
$out.HashList | ForEach-Object {
|
||||
if ($_ -isnot [hashtable] -or -not $_.ContainsKey('Hash') -or $_.Hash -isnot [string] -or $_.Hash.Length -ne 64) {
|
||||
throw "expecting hash list to contain hashtable with Hash key with a value of a SHA256 strings."
|
||||
}
|
||||
|
||||
if ($_.Mode -eq 'Trusted') {
|
||||
$null = $Script:AnsibleTrustedHashList.Add($_.Hash)
|
||||
}
|
||||
elseif ($_.Mode -eq 'Unsupported') {
|
||||
# Allows us to provide a better error when trying to run
|
||||
# something in CLM that is marked as unsupported.
|
||||
$null = $Script:AnsibleUnsupportedHashList.Add($_.Hash)
|
||||
}
|
||||
else {
|
||||
throw "expecting hash list entry for $($_.Hash) to contain a mode of 'Trusted' or 'Unsupported' but got '$($_.Mode)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$_.ErrorDetails = [ErrorDetails]::new("Failed to process signed manifest '$Name': $_")
|
||||
$PSCmdlet.WriteError($_)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Function New-TempAnsibleFile {
|
||||
[OutputType([string])]
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$FileName,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]
|
||||
$Content
|
||||
)
|
||||
|
||||
$name = [Path]::GetFileNameWithoutExtension($FileName)
|
||||
$ext = [Path]::GetExtension($FileName)
|
||||
$newName = "$($name)-$([Guid]::NewGuid())$ext"
|
||||
|
||||
$path = Join-Path -Path $Script:AnsibleTempPath $newName
|
||||
Set-WinPSDefaultFileEncoding
|
||||
[File]::WriteAllText($path, $Content, [UTF8Encoding]::new($false))
|
||||
|
||||
$path
|
||||
}
|
||||
|
||||
Function Set-WinPSDefaultFileEncoding {
|
||||
[CmdletBinding()]
|
||||
param ()
|
||||
|
||||
# WinPS defaults to the locale encoding when loading a script from the
|
||||
# file path but in Ansible we expect it to always be UTF-8 without a
|
||||
# BOM. This lazily sets an internal field so pwsh reads it as UTF-8.
|
||||
# If we don't do this then scripts saved as UTF-8 on the Ansible
|
||||
# controller will not run as expected.
|
||||
if ($PSVersionTable.PSVersion -lt '6.0' -and -not $Script:AnsibleClrFacadeSet) {
|
||||
$clrFacade = [PSObject].Assembly.GetType('System.Management.Automation.ClrFacade')
|
||||
$defaultEncodingField = $clrFacade.GetField('_defaultEncoding', [BindingFlags]'NonPublic, Static')
|
||||
$defaultEncodingField.SetValue($null, [UTF8Encoding]::new($false))
|
||||
$Script:AnsibleClrFacadeSet = $true
|
||||
}
|
||||
}
|
||||
|
||||
Function Write-AnsibleErrorJson {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
|
|
@ -414,6 +620,10 @@ begin {
|
|||
$Script:AnsibleManifest = $Manifest
|
||||
}
|
||||
|
||||
if ($Script:AnsibleShouldConstrain) {
|
||||
$Script:AnsibleManifest.signed_hashlist | Import-SignedHashList
|
||||
}
|
||||
|
||||
$actionInfo = Get-NextAnsibleAction
|
||||
$actionParams = $actionInfo.Parameters
|
||||
|
||||
|
|
@ -500,5 +710,8 @@ end {
|
|||
}
|
||||
finally {
|
||||
$actionPipeline.Dispose()
|
||||
if ($Script:AnsibleTempScripts) {
|
||||
Remove-Item -LiteralPath $Script:AnsibleTempScripts -Force -ErrorAction Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from ansible.plugins.loader import ps_module_utils_loader
|
|||
class _ExecManifest:
|
||||
scripts: dict[str, _ScriptInfo] = dataclasses.field(default_factory=dict)
|
||||
actions: list[_ManifestAction] = dataclasses.field(default_factory=list)
|
||||
signed_hashlist: list[str] = dataclasses.field(default_factory=list)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, kw_only=True)
|
||||
|
|
@ -54,6 +55,11 @@ class PSModuleDepFinder(object):
|
|||
def __init__(self) -> None:
|
||||
# This is also used by validate-modules to get a module's required utils in base and a collection.
|
||||
self.scripts: dict[str, _ScriptInfo] = {}
|
||||
self.signed_hashlist: set[str] = set()
|
||||
|
||||
if builtin_hashlist := _get_powershell_signed_hashlist():
|
||||
self.signed_hashlist.add(builtin_hashlist.path)
|
||||
self.scripts[builtin_hashlist.path] = builtin_hashlist
|
||||
|
||||
self._util_deps: dict[str, set[str]] = {}
|
||||
|
||||
|
|
@ -119,6 +125,15 @@ class PSModuleDepFinder(object):
|
|||
lines = module_data.split(b'\n')
|
||||
module_utils: set[tuple[str, str, bool]] = set()
|
||||
|
||||
if fqn and fqn.startswith("ansible_collections."):
|
||||
submodules = fqn.split('.')
|
||||
collection_name = '.'.join(submodules[:3])
|
||||
|
||||
collection_hashlist = _get_powershell_signed_hashlist(collection_name)
|
||||
if collection_hashlist and collection_hashlist.path not in self.signed_hashlist:
|
||||
self.signed_hashlist.add(collection_hashlist.path)
|
||||
self.scripts[collection_hashlist.path] = collection_hashlist
|
||||
|
||||
if powershell:
|
||||
checks = [
|
||||
# PS module contains '#Requires -Module Ansible.ModuleUtils.*'
|
||||
|
|
@ -315,6 +330,10 @@ def _bootstrap_powershell_script(
|
|||
)
|
||||
)
|
||||
|
||||
if hashlist := _get_powershell_signed_hashlist():
|
||||
exec_manifest.signed_hashlist.append(hashlist.path)
|
||||
exec_manifest.scripts[hashlist.path] = hashlist
|
||||
|
||||
bootstrap_wrapper = _get_powershell_script("bootstrap_wrapper.ps1")
|
||||
bootstrap_input = _get_bootstrap_input(exec_manifest)
|
||||
if has_input:
|
||||
|
|
@ -339,6 +358,14 @@ def _get_powershell_script(
|
|||
if code is None:
|
||||
raise AnsibleFileNotFound(f"Could not find powershell script '{package_name}.{name}'")
|
||||
|
||||
try:
|
||||
sig_data = pkgutil.get_data(package_name, f"{name}.authenticode")
|
||||
except FileNotFoundError:
|
||||
sig_data = None
|
||||
|
||||
if sig_data:
|
||||
code = code + b"\r\n" + b"\r\n".join(sig_data.splitlines()) + b"\r\n"
|
||||
|
||||
return code
|
||||
|
||||
|
||||
|
|
@ -501,6 +528,7 @@ def _create_powershell_wrapper(
|
|||
exec_manifest = _ExecManifest(
|
||||
scripts=finder.scripts,
|
||||
actions=actions,
|
||||
signed_hashlist=list(finder.signed_hashlist),
|
||||
)
|
||||
|
||||
return _get_bootstrap_input(
|
||||
|
|
@ -551,3 +579,27 @@ def _prepare_module_args(module_args: dict[str, t.Any], profile: str) -> dict[st
|
|||
encoder = get_module_encoder(profile, Direction.CONTROLLER_TO_MODULE)
|
||||
|
||||
return json.loads(json.dumps(module_args, cls=encoder))
|
||||
|
||||
|
||||
def _get_powershell_signed_hashlist(
|
||||
collection: str | None = None,
|
||||
) -> _ScriptInfo | None:
|
||||
"""Gets the signed hashlist script stored in either the Ansible package or for
|
||||
the collection specified.
|
||||
|
||||
:param collection: The collection namespace to get the signed hashlist for or None for the builtin.
|
||||
:return: The _ScriptInfo payload of the signed hashlist script if found, None if not.
|
||||
"""
|
||||
resource = 'ansible.config' if collection is None else f"{collection}.meta"
|
||||
signature_file = 'powershell_signatures.psd1'
|
||||
|
||||
try:
|
||||
sig_data = pkgutil.get_data(resource, signature_file)
|
||||
except FileNotFoundError:
|
||||
sig_data = None
|
||||
|
||||
if sig_data:
|
||||
resource_path = f"{resource}.{signature_file}"
|
||||
return _ScriptInfo(content=sig_data, path=resource_path)
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ $ps = [PowerShell]::Create()
|
|||
if ($ForModule) {
|
||||
$ps.Runspace.SessionStateProxy.SetVariable("ErrorActionPreference", "Stop")
|
||||
}
|
||||
else {
|
||||
# For script files we want to ensure we load it as UTF-8
|
||||
Set-WinPSDefaultFileEncoding
|
||||
}
|
||||
|
||||
foreach ($variable in $Variables) {
|
||||
$null = $ps.AddCommand("Set-Variable").AddParameters($variable).AddStatement()
|
||||
|
|
@ -112,12 +116,31 @@ foreach ($env in $Environment.GetEnumerator()) {
|
|||
$null = $ps.AddScript('Function Write-Host($msg) { Write-Output -InputObject $msg }').AddStatement()
|
||||
|
||||
$scriptInfo = Get-AnsibleScript -Name $Script
|
||||
if ($scriptInfo.ShouldConstrain) {
|
||||
# Fail if there are any module utils, in the future we may allow unsigned
|
||||
# PowerShell utils in CLM but for now we don't.
|
||||
if ($PowerShellModules -or $CSharpModules) {
|
||||
throw "Cannot run untrusted PowerShell script '$Script' in ConstrainedLanguage mode with module util imports."
|
||||
}
|
||||
|
||||
if ($PowerShellModules) {
|
||||
foreach ($utilName in $PowerShellModules) {
|
||||
$utilInfo = Get-AnsibleScript -Name $utilName
|
||||
# If the module is marked as needing to be constrained then we set the
|
||||
# language mode to ConstrainedLanguage so that when parsed inside the
|
||||
# Runspace it will run in CLM. We need to run it from a filepath as in
|
||||
# CLM we cannot call the methods needed to create the ScriptBlock and we
|
||||
# need to be in CLM to downgrade the language mode.
|
||||
$null = $ps.AddScript('$ExecutionContext.SessionState.LanguageMode = "ConstrainedLanguage"').AddStatement()
|
||||
$scriptPath = New-TempAnsibleFile -FileName $Script -Content $scriptInfo.Script
|
||||
$null = $ps.AddCommand($scriptPath, $false).AddStatement()
|
||||
}
|
||||
else {
|
||||
if ($PowerShellModules) {
|
||||
foreach ($utilName in $PowerShellModules) {
|
||||
$utilInfo = Get-AnsibleScript -Name $utilName
|
||||
if ($utilInfo.ShouldConstrain) {
|
||||
throw "PowerShell module util '$utilName' is not trusted and cannot be loaded."
|
||||
}
|
||||
|
||||
$null = $ps.AddScript(@'
|
||||
$null = $ps.AddScript(@'
|
||||
param ($Name, $Script)
|
||||
|
||||
$moduleName = [System.IO.Path]::GetFileNameWithoutExtension($Name)
|
||||
|
|
@ -130,32 +153,33 @@ $sbk = [System.Management.Automation.Language.Parser]::ParseInput(
|
|||
New-Module -Name $moduleName -ScriptBlock $sbk |
|
||||
Import-Module -WarningAction SilentlyContinue -Scope Global
|
||||
'@, $true)
|
||||
$null = $ps.AddParameters(
|
||||
@{
|
||||
Name = $utilName
|
||||
Script = $utilInfo.Script
|
||||
}
|
||||
).AddStatement()
|
||||
$null = $ps.AddParameters(
|
||||
@{
|
||||
Name = $utilName
|
||||
Script = $utilInfo.Script
|
||||
}
|
||||
).AddStatement()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($CSharpModules) {
|
||||
# C# utils are process wide so just load them here.
|
||||
Import-CSharpUtil -Name $CSharpModules
|
||||
}
|
||||
if ($CSharpModules) {
|
||||
# C# utils are process wide so just load them here.
|
||||
Import-CSharpUtil -Name $CSharpModules
|
||||
}
|
||||
|
||||
# We invoke it through a command with useLocalScope $false to
|
||||
# ensure the code runs with it's own $script: scope. It also
|
||||
# cleans up the StackTrace on errors by not showing the stub
|
||||
# execution line and starts immediately at the module "cmd".
|
||||
$null = $ps.AddScript(@'
|
||||
# We invoke it through a command with useLocalScope $false to
|
||||
# ensure the code runs with it's own $script: scope. It also
|
||||
# cleans up the StackTrace on errors by not showing the stub
|
||||
# execution line and starts immediately at the module "cmd".
|
||||
$null = $ps.AddScript(@'
|
||||
${function:<AnsibleModule>} = [System.Management.Automation.Language.Parser]::ParseInput(
|
||||
$args[0],
|
||||
$args[1],
|
||||
[ref]$null,
|
||||
[ref]$null).GetScriptBlock()
|
||||
'@).AddArgument($scriptInfo.Script).AddArgument($Script).AddStatement()
|
||||
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
|
||||
$null = $ps.AddCommand('<AnsibleModule>', $false).AddStatement()
|
||||
}
|
||||
|
||||
if ($Breakpoints) {
|
||||
$ps.Runspace.Debugger.SetBreakpoints($Breakpoints)
|
||||
|
|
|
|||
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()
|
||||
basefile = self._connection._shell._generate_temp_dir_name()
|
||||
cmd = self._connection._shell.mkdtemp(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
|
||||
result = self._low_level_execute_command(cmd, sudoable=False)
|
||||
cmd = self._connection._shell._mkdtemp2(basefile=basefile, system=become_unprivileged, tmpdir=tmpdir)
|
||||
result = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
|
||||
|
||||
# error handling on this seems a little aggressive?
|
||||
if result['rc'] != 0:
|
||||
|
|
@ -906,8 +906,8 @@ class ActionBase(ABC, _AnsiblePluginInfoMixin):
|
|||
expand_path = '~%s' % (self._get_remote_user() or '')
|
||||
|
||||
# use shell to construct appropriate command and execute
|
||||
cmd = self._connection._shell.expand_user(expand_path)
|
||||
data = self._low_level_execute_command(cmd, sudoable=False)
|
||||
cmd = self._connection._shell._expand_user2(expand_path)
|
||||
data = self._low_level_execute_command(cmd.command, in_data=cmd.input_data, sudoable=False)
|
||||
|
||||
try:
|
||||
initial_fragment = data['stdout'].strip().splitlines()[-1]
|
||||
|
|
|
|||
|
|
@ -723,8 +723,11 @@ class Connection(ConnectionBase):
|
|||
super(Connection, self).exec_command(cmd, in_data=in_data, sudoable=sudoable)
|
||||
|
||||
encoded_prefix = self._shell._encode_script('', as_list=False, strict_mode=False, preserve_rc=False)
|
||||
if cmd.startswith(encoded_prefix):
|
||||
# Avoid double encoding the script
|
||||
if cmd.startswith(encoded_prefix) or cmd.startswith("type "):
|
||||
# Avoid double encoding the script, the first means we are already
|
||||
# running the standard PowerShell command, the latter is used for
|
||||
# the no pipeline case where it uses type to pipe the script into
|
||||
# powershell which is known to work without re-encoding as pwsh.
|
||||
cmd_parts = cmd.split(" ")
|
||||
else:
|
||||
cmd_parts = self._shell._encode_script(cmd, as_list=True, strict_mode=False, preserve_rc=False)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import os.path
|
||||
import re
|
||||
|
|
@ -33,6 +34,13 @@ from ansible.plugins import AnsiblePlugin
|
|||
_USER_HOME_PATH_RE = re.compile(r'^~[_.A-Za-z0-9][-_.A-Za-z0-9]*$')
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class _ShellCommand:
|
||||
"""Internal type returned by shell subsystems that may require both an execution payload and a command (eg powershell)."""
|
||||
command: str
|
||||
input_data: bytes | None = None
|
||||
|
||||
|
||||
class ShellBase(AnsiblePlugin):
|
||||
def __init__(self):
|
||||
|
||||
|
|
@ -121,7 +129,13 @@ class ShellBase(AnsiblePlugin):
|
|||
cmd = ['test', '-e', self.quote(path)]
|
||||
return ' '.join(cmd)
|
||||
|
||||
def mkdtemp(self, basefile=None, system=False, mode=0o700, tmpdir=None):
|
||||
def mkdtemp(
|
||||
self,
|
||||
basefile: str | None = None,
|
||||
system: bool = False,
|
||||
mode: int = 0o700,
|
||||
tmpdir: str | None = None,
|
||||
) -> str:
|
||||
if not basefile:
|
||||
basefile = self.__class__._generate_temp_dir_name()
|
||||
|
||||
|
|
@ -163,7 +177,31 @@ class ShellBase(AnsiblePlugin):
|
|||
|
||||
return cmd
|
||||
|
||||
def expand_user(self, user_home_path, username=''):
|
||||
def _mkdtemp2(
|
||||
self,
|
||||
basefile: str | None = None,
|
||||
system: bool = False,
|
||||
mode: int = 0o700,
|
||||
tmpdir: str | None = None,
|
||||
) -> _ShellCommand:
|
||||
"""Gets command info to create a temporary directory.
|
||||
|
||||
This is an internal API that should not be used publicly.
|
||||
|
||||
:args basefile: The base name of the temporary directory.
|
||||
:args system: If True, create the directory in a system-wide location.
|
||||
:args mode: The permissions mode for the directory.
|
||||
:args tmpdir: The directory in which to create the temporary directory.
|
||||
:returns: The shell command to run to create the temp directory.
|
||||
"""
|
||||
cmd = self.mkdtemp(basefile=basefile, system=system, mode=mode, tmpdir=tmpdir)
|
||||
return _ShellCommand(command=cmd, input_data=None)
|
||||
|
||||
def expand_user(
|
||||
self,
|
||||
user_home_path: str,
|
||||
username: str = '',
|
||||
) -> str:
|
||||
""" Return a command to expand tildes in a path
|
||||
|
||||
It can be either "~" or "~username". We just ignore $HOME
|
||||
|
|
@ -184,6 +222,22 @@ class ShellBase(AnsiblePlugin):
|
|||
|
||||
return 'echo %s' % user_home_path
|
||||
|
||||
def _expand_user2(
|
||||
self,
|
||||
user_home_path: str,
|
||||
username: str = '',
|
||||
) -> _ShellCommand:
|
||||
"""Gets command to expand user path.
|
||||
|
||||
This is an internal API that should not be used publicly.
|
||||
|
||||
:args user_home_path: The path to expand.
|
||||
:args username: The username to use for expansion.
|
||||
:returns: The shell command to run to get the expanded user path.
|
||||
"""
|
||||
cmd = self.expand_user(user_home_path, username=username)
|
||||
return _ShellCommand(command=cmd, input_data=None)
|
||||
|
||||
def pwd(self):
|
||||
"""Return the working directory after connecting"""
|
||||
return 'echo %spwd%s' % (self._SHELL_SUB_LEFT, self._SHELL_SUB_RIGHT)
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ import shlex
|
|||
import xml.etree.ElementTree as ET
|
||||
import ntpath
|
||||
|
||||
from ansible.executor.powershell.module_manifest import _get_powershell_script
|
||||
from ansible.executor.powershell.module_manifest import _bootstrap_powershell_script, _get_powershell_script
|
||||
from ansible.module_utils.common.text.converters import to_bytes, to_text
|
||||
from ansible.plugins.shell import ShellBase
|
||||
from ansible.plugins.shell import ShellBase, _ShellCommand
|
||||
|
||||
# This is weird, we are matching on byte sequences that match the utf-16-be
|
||||
# matches for '_x(a-fA-F0-9){4}_'. The \x00 and {4} will match the hex sequence
|
||||
|
|
@ -225,9 +225,15 @@ class ShellModule(ShellBase):
|
|||
else:
|
||||
return self._encode_script("""Remove-Item '%s' -Force;""" % path)
|
||||
|
||||
def mkdtemp(self, basefile=None, system=False, mode=None, tmpdir=None):
|
||||
# Windows does not have an equivalent for the system temp files, so
|
||||
# the param is ignored
|
||||
def mkdtemp(
|
||||
self,
|
||||
basefile: str | None = None,
|
||||
system: bool = False,
|
||||
mode: int = 0o700,
|
||||
tmpdir: str | None = None,
|
||||
) -> str:
|
||||
# This is not called in Ansible anymore but it is kept for backwards
|
||||
# compatibility in case other action plugins outside Ansible calls this.
|
||||
if not basefile:
|
||||
basefile = self.__class__._generate_temp_dir_name()
|
||||
basefile = self._escape(self._unquote(basefile))
|
||||
|
|
@ -241,10 +247,38 @@ class ShellModule(ShellBase):
|
|||
"""
|
||||
return self._encode_script(script.strip())
|
||||
|
||||
def expand_user(self, user_home_path, username=''):
|
||||
# PowerShell only supports "~" (not "~username"). Resolve-Path ~ does
|
||||
# not seem to work remotely, though by default we are always starting
|
||||
# in the user's home directory.
|
||||
def _mkdtemp2(
|
||||
self,
|
||||
basefile: str | None = None,
|
||||
system: bool = False,
|
||||
mode: int = 0o700,
|
||||
tmpdir: str | None = None,
|
||||
) -> _ShellCommand:
|
||||
# Windows does not have an equivalent for the system temp files, so
|
||||
# the param is ignored
|
||||
if not basefile:
|
||||
basefile = self.__class__._generate_temp_dir_name()
|
||||
|
||||
basefile = self._unquote(basefile)
|
||||
basetmpdir = tmpdir if tmpdir else self.get_option('remote_tmp')
|
||||
|
||||
script, stdin = _bootstrap_powershell_script("powershell_mkdtemp.ps1", {
|
||||
'Directory': basetmpdir,
|
||||
'Name': basefile,
|
||||
})
|
||||
|
||||
return _ShellCommand(
|
||||
command=self._encode_script(script),
|
||||
input_data=stdin,
|
||||
)
|
||||
|
||||
def expand_user(
|
||||
self,
|
||||
user_home_path: str,
|
||||
username: str = '',
|
||||
) -> str:
|
||||
# This is not called in Ansible anymore but it is kept for backwards
|
||||
# compatibility in case other actions plugins outside Ansible called this.
|
||||
user_home_path = self._unquote(user_home_path)
|
||||
if user_home_path == '~':
|
||||
script = 'Write-Output (Get-Location).Path'
|
||||
|
|
@ -254,6 +288,21 @@ class ShellModule(ShellBase):
|
|||
script = "Write-Output '%s'" % self._escape(user_home_path)
|
||||
return self._encode_script(f"{self._CONSOLE_ENCODING}; {script}")
|
||||
|
||||
def _expand_user2(
|
||||
self,
|
||||
user_home_path: str,
|
||||
username: str = '',
|
||||
) -> _ShellCommand:
|
||||
user_home_path = self._unquote(user_home_path)
|
||||
script, stdin = _bootstrap_powershell_script("powershell_expand_user.ps1", {
|
||||
'Path': user_home_path,
|
||||
})
|
||||
|
||||
return _ShellCommand(
|
||||
command=self._encode_script(script),
|
||||
input_data=stdin,
|
||||
)
|
||||
|
||||
def exists(self, path):
|
||||
path = self._escape(self._unquote(path))
|
||||
script = """
|
||||
|
|
|
|||
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.missing_using_namespace == True
|
||||
- exec_wrapper_scope.util_res.script_var == 'bar'
|
||||
|
||||
- name: test module without any util references
|
||||
test_no_utils:
|
||||
foo: bar
|
||||
register: no_utils_res
|
||||
|
||||
- name: assert test module without any util references
|
||||
assert:
|
||||
that:
|
||||
- no_utils_res is not changed
|
||||
- "no_utils_res.complex_args == {'foo': 'bar'}"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
'ünicode'
|
||||
|
|
@ -338,3 +338,12 @@
|
|||
# SSH includes debug output in stderr, and WinRM on 2016 includes a trailing newline
|
||||
# Use a simple search to ensure the expected stderr is present but ignoring any extra output
|
||||
- script_stderr.stderr is search("stderr 1\r\nstderr 2\r\n")
|
||||
|
||||
- name: run script with non-ASCII contents
|
||||
script: test_script_unicode.ps1
|
||||
register: script_unicode
|
||||
|
||||
- name: assert run script with non-ASCII contents
|
||||
assert:
|
||||
that:
|
||||
- script_unicode.stdout | trim == 'ünicode'
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ test/integration/targets/template/templates/encoding_1252.j2 no-smart-quotes
|
|||
test/integration/targets/template/templates/encoding_1252.j2 no-unwanted-characters
|
||||
test/integration/targets/unicode/unicode.yml no-smart-quotes
|
||||
test/integration/targets/windows-minimal/library/win_ping_syntax_error.ps1 pslint!skip
|
||||
test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 pslint:PSCustomUseLiteralPath # We want to use wildcard matching with -Path
|
||||
test/integration/targets/win_app_control/files/New-AnsiblePowerShellSignature.ps1 shebang # We want to run with pwsh from the environment in the test
|
||||
test/integration/targets/win_app_control/files/Set-ManifestSignature.ps1 shebang # We want to run with pwsh from the environment in the test
|
||||
test/integration/targets/win_exec_wrapper/library/test_fail.ps1 pslint:PSCustomUseLiteralPath
|
||||
test/integration/targets/win_exec_wrapper/tasks/main.yml no-smart-quotes # We are explicitly testing smart quote support for env vars
|
||||
test/integration/targets/win_fetch/tasks/main.yml no-smart-quotes # We are explictly testing smart quotes in the file name to fetch
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from ansible.module_utils.common.text.converters import to_bytes
|
|||
from ansible._internal._datatag._tags import TrustedAsTemplate
|
||||
from ansible.playbook.play_context import PlayContext
|
||||
from ansible.plugins.action import ActionBase
|
||||
from ansible.plugins.shell import _ShellCommand
|
||||
from ansible.vars.clean import clean_facts
|
||||
from ansible.template import Templar
|
||||
from ansible.plugins import loader
|
||||
|
|
@ -293,7 +294,7 @@ class TestActionBase(unittest.TestCase):
|
|||
# create a mock connection, so we don't actually try and connect to things
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.transport = 'ssh'
|
||||
mock_connection._shell.mkdtemp.return_value = 'mkdir command'
|
||||
mock_connection._shell._mkdtemp2.return_value = _ShellCommand(command='mkdir command')
|
||||
mock_connection._shell.join_path.side_effect = os.path.join
|
||||
mock_connection._shell.get_option = get_shell_opt
|
||||
mock_connection._shell.HOMES_RE = re.compile(r'(\'|\")?(~|\$HOME)(.*)')
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user