diff --git a/Tasks/AzureFileCopyV1/task.json b/Tasks/AzureFileCopyV1/task.json index c5efa6cccb4d..57720e5fe6e8 100644 --- a/Tasks/AzureFileCopyV1/task.json +++ b/Tasks/AzureFileCopyV1/task.json @@ -14,7 +14,7 @@ "version": { "Major": 1, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV1/task.loc.json b/Tasks/AzureFileCopyV1/task.loc.json index e17d5fedaa05..e915f3c742e1 100644 --- a/Tasks/AzureFileCopyV1/task.loc.json +++ b/Tasks/AzureFileCopyV1/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 1, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV2/task.json b/Tasks/AzureFileCopyV2/task.json index 3477a77e8b8f..ed08c9d320b4 100644 --- a/Tasks/AzureFileCopyV2/task.json +++ b/Tasks/AzureFileCopyV2/task.json @@ -14,7 +14,7 @@ "version": { "Major": 2, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV2/task.loc.json b/Tasks/AzureFileCopyV2/task.loc.json index 437c2cf9c694..a1bdb41c5e6c 100644 --- a/Tasks/AzureFileCopyV2/task.loc.json +++ b/Tasks/AzureFileCopyV2/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 2, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV3/task.json b/Tasks/AzureFileCopyV3/task.json index 06e7f4f1fc61..b2ea5cc52a5a 100644 --- a/Tasks/AzureFileCopyV3/task.json +++ b/Tasks/AzureFileCopyV3/task.json @@ -14,7 +14,7 @@ "version": { "Major": 3, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV3/task.loc.json b/Tasks/AzureFileCopyV3/task.loc.json index dbfc50de6c58..27efee798547 100644 --- a/Tasks/AzureFileCopyV3/task.loc.json +++ b/Tasks/AzureFileCopyV3/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 3, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV4/task.json b/Tasks/AzureFileCopyV4/task.json index 1015920c4f35..65919df7704b 100644 --- a/Tasks/AzureFileCopyV4/task.json +++ b/Tasks/AzureFileCopyV4/task.json @@ -14,7 +14,7 @@ "version": { "Major": 4, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV4/task.loc.json b/Tasks/AzureFileCopyV4/task.loc.json index 2e25b673f839..8bca66a8a30f 100644 --- a/Tasks/AzureFileCopyV4/task.loc.json +++ b/Tasks/AzureFileCopyV4/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 4, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV5/task.json b/Tasks/AzureFileCopyV5/task.json index 25afe40eb1bd..8987108075b3 100644 --- a/Tasks/AzureFileCopyV5/task.json +++ b/Tasks/AzureFileCopyV5/task.json @@ -14,7 +14,7 @@ "version": { "Major": 5, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV5/task.loc.json b/Tasks/AzureFileCopyV5/task.loc.json index 6c15428916dd..668accc204b4 100644 --- a/Tasks/AzureFileCopyV5/task.loc.json +++ b/Tasks/AzureFileCopyV5/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 5, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV6/task.json b/Tasks/AzureFileCopyV6/task.json index 29edb26c4c1d..bfedf3764772 100644 --- a/Tasks/AzureFileCopyV6/task.json +++ b/Tasks/AzureFileCopyV6/task.json @@ -14,7 +14,7 @@ "version": { "Major": 6, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzureFileCopyV6/task.loc.json b/Tasks/AzureFileCopyV6/task.loc.json index 237f98668de5..98a613e06089 100644 --- a/Tasks/AzureFileCopyV6/task.loc.json +++ b/Tasks/AzureFileCopyV6/task.loc.json @@ -14,7 +14,7 @@ "version": { "Major": 6, "Minor": 276, - "Patch": 0 + "Patch": 2 }, "demands": [ "azureps" diff --git a/Tasks/AzurePowerShellV2/task.json b/Tasks/AzurePowerShellV2/task.json index 4ab92f660697..0b86f51b877f 100644 --- a/Tasks/AzurePowerShellV2/task.json +++ b/Tasks/AzurePowerShellV2/task.json @@ -18,7 +18,7 @@ "version": { "Major": 2, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzurePowerShellV2/task.loc.json b/Tasks/AzurePowerShellV2/task.loc.json index b95c9639c7f2..d8c5fd362719 100644 --- a/Tasks/AzurePowerShellV2/task.loc.json +++ b/Tasks/AzurePowerShellV2/task.loc.json @@ -18,7 +18,7 @@ "version": { "Major": 2, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "demands": [ "azureps" diff --git a/Tasks/AzurePowerShellV3/task.json b/Tasks/AzurePowerShellV3/task.json index e7b485184fdb..d89c7e3723db 100644 --- a/Tasks/AzurePowerShellV3/task.json +++ b/Tasks/AzurePowerShellV3/task.json @@ -18,7 +18,7 @@ "version": { "Major": 3, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "releaseNotes": "Added support for Fail on standard error and ErrorActionPreference", "demands": [ diff --git a/Tasks/AzurePowerShellV3/task.loc.json b/Tasks/AzurePowerShellV3/task.loc.json index 25d8cd818756..c85cfe09191b 100644 --- a/Tasks/AzurePowerShellV3/task.loc.json +++ b/Tasks/AzurePowerShellV3/task.loc.json @@ -18,7 +18,7 @@ "version": { "Major": 3, "Minor": 276, - "Patch": 0 + "Patch": 1 }, "releaseNotes": "ms-resource:loc.releaseNotes", "demands": [ diff --git a/Tasks/AzurePowerShellV4/task.json b/Tasks/AzurePowerShellV4/task.json index d37271bf3de2..ad7e8d271e1c 100644 --- a/Tasks/AzurePowerShellV4/task.json +++ b/Tasks/AzurePowerShellV4/task.json @@ -18,7 +18,7 @@ "version": { "Major": 4, "Minor": 276, - "Patch": 2 + "Patch": 4 }, "releaseNotes": "Added support for Az Module and cross platform agents.", "groups": [ diff --git a/Tasks/AzurePowerShellV4/task.loc.json b/Tasks/AzurePowerShellV4/task.loc.json index d8e44b62e16b..10b3ad3e921a 100644 --- a/Tasks/AzurePowerShellV4/task.loc.json +++ b/Tasks/AzurePowerShellV4/task.loc.json @@ -18,7 +18,7 @@ "version": { "Major": 4, "Minor": 276, - "Patch": 2 + "Patch": 4 }, "releaseNotes": "ms-resource:loc.releaseNotes", "groups": [ diff --git a/Tasks/AzurePowerShellV5/task.json b/Tasks/AzurePowerShellV5/task.json index 1650b75aea07..398009af31a5 100644 --- a/Tasks/AzurePowerShellV5/task.json +++ b/Tasks/AzurePowerShellV5/task.json @@ -18,7 +18,7 @@ "version": { "Major": 5, "Minor": 276, - "Patch": 2 + "Patch": 4 }, "releaseNotes": "Added support for Az Module and cross platform agents.", "groups": [ diff --git a/Tasks/AzurePowerShellV5/task.loc.json b/Tasks/AzurePowerShellV5/task.loc.json index 86905b90f926..7437d9d488a3 100644 --- a/Tasks/AzurePowerShellV5/task.loc.json +++ b/Tasks/AzurePowerShellV5/task.loc.json @@ -18,7 +18,7 @@ "version": { "Major": 5, "Minor": 276, - "Patch": 2 + "Patch": 4 }, "releaseNotes": "ms-resource:loc.releaseNotes", "groups": [ diff --git a/Tasks/Common/Sanitizer/ArgumentsSanitizer.ps1 b/Tasks/Common/Sanitizer/ArgumentsSanitizer.ps1 index 0b3ea2f2757c..f11846a39280 100644 --- a/Tasks/Common/Sanitizer/ArgumentsSanitizer.ps1 +++ b/Tasks/Common/Sanitizer/ArgumentsSanitizer.ps1 @@ -12,6 +12,24 @@ Write-Verbose "Feature flag AZP_75787_ENABLE_COLLECT state: $($featureFlags.tele $taskName = "" +# AST node types that represent code execution (not pure data) and therefore +# must never appear in a relaxed-mode argument. Kept in one module-level place so +# the set is easy to audit and extend. +# ScriptBlockExpressionAst - { ... } +# MemberExpressionAst - property / method access; its subclass +# InvokeMemberExpressionAst (method calls) is covered too +# ConvertExpressionAst - [type]$x / [type]'s' casts, incl. [ordered]@{} / [pscustomobject]@{} +# TypeExpressionAst - a bare [type] reference (also the right side of -is / -isnot) +# The -as conversion operator is handled separately in Test-SanitizerArgumentAst +# because it is a BinaryExpressionAst distinguished by its operator, not a +# dedicated node type. +$script:DangerousAstNodeTypes = @( + [System.Management.Automation.Language.ScriptBlockExpressionAst], + [System.Management.Automation.Language.MemberExpressionAst], + [System.Management.Automation.Language.ConvertExpressionAst], + [System.Management.Automation.Language.TypeExpressionAst] +) + # public functions - start function Get-SanitizerFeatureFlags { @@ -30,14 +48,26 @@ function Get-SanitizerActivateStatus { # This is a wrapper for Get-SanitizedArguments to handle feature flags in one place # It will return sanitized arguments string if feature flag is enabled -function Protect-ScriptArguments([string]$inputArgs, [string]$taskName) { +function Protect-ScriptArguments([string]$inputArgs, [string]$taskName, [switch]$AllowDataConstructors) { $script:taskName = $taskName + # In the relaxed mode, run the structural AST backstop on the RAW arguments + # first. This module only validates - it does not rewrite what the task runs - + # so the raw string is exactly what PowerShell parses at the dot-source sink. + # The relaxed allow-list permits @ { } [ ], which re-enables expressions that + # evaluate at bind time (a hashtable value, cast or sub-expression); + # Test-SanitizerArgumentAst rejects those while still allowing pure data + # literals such as @{ Port = 8080 }. + $astSafe = $true + if ($AllowDataConstructors) { + $astSafe = Test-SanitizerArgumentAst $inputArgs + } + $expandedArgs, $expandTelemetry = Expand-EnvVariables $inputArgs; - $sanitizedArgs, $sanitizeTelemetry = Get-SanitizedArguments -InputArgs $expandedArgs + $sanitizedArgs, $sanitizeTelemetry = Get-SanitizedArguments -InputArgs $expandedArgs -AllowDataConstructors:$AllowDataConstructors - if ($sanitizedArgs -eq $inputArgs) { + if (($sanitizedArgs -eq $inputArgs) -and $astSafe) { Write-Debug 'Arguments passed sanitization without change.' } else { @@ -46,10 +76,16 @@ function Protect-ScriptArguments([string]$inputArgs, [string]$taskName) { if ($null -ne $sanitizeTelemetry) { $telemetry += $sanitizeTelemetry; } + if (-not $astSafe) { + if ($null -eq $telemetry) { + $telemetry = @{} + } + $telemetry.astBackstopRejected = $true + } Publish-Telemetry $telemetry; } - if ($sanitizedArgs -ne $expandedArgs) { + if (($sanitizedArgs -ne $expandedArgs) -or (-not $astSafe)) { $message = (Get-VstsLocString -Key 'PS_ScriptArgsSanitized'); if ($featureFlags.activate) { @@ -69,16 +105,32 @@ function Protect-ScriptArguments([string]$inputArgs, [string]$taskName) { # public functions - end # !ATTENTION: don't write any console output in this method, because it will break result -function Get-SanitizedArguments([string]$inputArgs) { +function Get-SanitizedArguments([string]$inputArgs, [switch]$AllowDataConstructors) { $removedSymbolSign = '_#removed#_'; $argsSplitSymbols = '``'; [string[][]]$matchesChunks = @() ## PowerShell Regex is case insensitive by default, so we don't need to specify a-zA-Z. ## ('? { + psr.run(path.join(__dirname, 'L0Test-SanitizerArgumentAst.ps1'), done); + }); + } + + if (psm.testSupported()) { + it('Protect-ScriptArguments allows legitimate data constructors on the relaxed path', (done) => { + psr.run(path.join(__dirname, 'L0Protect-ScriptArguments.AllowsDataConstructors.ps1'), done); + }); + } + + if (psm.testSupported()) { + it('Protect-ScriptArguments blocks data-constructor injection via the AST backstop', (done) => { + psr.run(path.join(__dirname, 'L0Protect-ScriptArguments.BlocksDataConstructorInjection.ps1'), done); + }); + } + + if (psm.testSupported()) { + it('Get-SanitizedArguments keeps the strict path unchanged for legacy callers', (done) => { + psr.run(path.join(__dirname, 'L0Get-SanitizedArguments.GroupBIsolation.ps1'), done); + }); + } }); diff --git a/Tasks/Common/Sanitizer/Tests/L0Get-SanitizedArguments.GroupBIsolation.ps1 b/Tasks/Common/Sanitizer/Tests/L0Get-SanitizedArguments.GroupBIsolation.ps1 new file mode 100644 index 000000000000..44ff25625f15 --- /dev/null +++ b/Tasks/Common/Sanitizer/Tests/L0Get-SanitizedArguments.GroupBIsolation.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +param() + +. $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 +. $PSScriptRoot\..\ArgumentsSanitizer.ps1 + +# Isolation lock. Legacy direct callers (AzureFileCopy, PowerShellV2, +# PowerShellOnTargetMachines, WindowsMachineFileCopy, Sql*Deployment) call +# Get-SanitizedArguments / Protect-ScriptArguments WITHOUT -AllowDataConstructors. +# That strict path must stay byte-for-byte unchanged: the data-constructor +# characters @ { } [ ] are still treated as forbidden and the AST backstop never +# runs. Only the new opt-in dispatcher path relaxes them. +$dataConstructorInput = '@{ Port = 8080 }' + +$strict, $null = Get-SanitizedArguments -InputArgs $dataConstructorInput +$relaxed, $null = Get-SanitizedArguments -InputArgs $dataConstructorInput -AllowDataConstructors + +# Strict (default) path still strips @ { } - legacy behavior preserved. +Assert-AreNotEqual -NotExpected $dataConstructorInput -Actual $strict ` + -Message "Strict path must continue to sanitize data-constructor characters for legacy callers" + +# Relaxed path leaves the legitimate data constructor intact. +Assert-AreEqual -Expected $dataConstructorInput -Actual $relaxed ` + -Message "Relaxed path must preserve legitimate data constructors" + +# A bracket-only input behaves the same way: stripped by default, preserved with the switch. +$bracketInput = 'value[0]' +$strictBracket, $null = Get-SanitizedArguments -InputArgs $bracketInput +$relaxedBracket, $null = Get-SanitizedArguments -InputArgs $bracketInput -AllowDataConstructors + +Assert-AreNotEqual -NotExpected $bracketInput -Actual $strictBracket ` + -Message "Strict path must continue to sanitize bracket characters for legacy callers" +Assert-AreEqual -Expected $bracketInput -Actual $relaxedBracket ` + -Message "Relaxed path must preserve bracket characters" diff --git a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.AllowsDataConstructors.ps1 b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.AllowsDataConstructors.ps1 new file mode 100644 index 000000000000..a4d85f6326b3 --- /dev/null +++ b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.AllowsDataConstructors.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param() + +$originalEnableNewLogic = $env:AZP_75787_ENABLE_NEW_LOGIC +Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value 'true' + +. $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 +. $PSScriptRoot\..\ArgumentsSanitizer.ps1 + +try { + +# With -AllowDataConstructors (the path the dispatcher uses for AzurePowerShell +# and ServiceFabricPowerShell) legitimate hashtable arguments, splatting, bare +# type / index literals, quoted values and environment variables must pass +# without being rejected, even though the strict allow-list mangles @ { } [ ]. +$benignInputs = @( + @{ Name = 'Plain hashtable'; Input = '@{ Port = 8080 }' }, + @{ Name = 'Hashtable string value'; Input = '@{ Owner = "user@contoso.com" }' }, + @{ Name = 'Named hashtable parameter'; Input = '-Tags @{ env = "prod" }' }, + @{ Name = 'Bare type literal'; Input = '[string]' }, + @{ Name = 'Type literal parameter'; Input = '-Type [System.String]' }, + @{ Name = 'Index literal'; Input = '[0]' }, + @{ Name = 'Splatting variable'; Input = '@params' }, + @{ Name = 'Boolean'; Input = '-Enabled $true' }, + @{ Name = 'Ordinary parameters'; Input = '-Param1 value1 -Param2 value2' }, + @{ Name = 'Env variable expansion'; Input = 'test $env:VAR1 done'; Variables = @('VAR1=hello') } +) + +foreach ($test in $benignInputs) { + if ($null -eq $test.Variables) { $test.Variables = @() } + $test.Variables | ForEach-Object { + $name, $value = $_.Split('=') + if ($value) { Set-Item -Path env:$name -Value $value } else { Remove-Item env:$name -ErrorAction SilentlyContinue } + } + + try { + Protect-ScriptArguments -InputArgs $test.Input -AllowDataConstructors + } + catch { + throw "Expected '$($test.Name)' input [$($test.Input)] to pass the relaxed sanitizer, but it threw: $($_.Exception.Message)" + } + finally { + $test.Variables | ForEach-Object { + $name, $value = $_.Split('=') + Remove-Item env:$name -ErrorAction SilentlyContinue + } + } +} +} +finally { + if ($null -eq $originalEnableNewLogic) { + Remove-Item env:AZP_75787_ENABLE_NEW_LOGIC -ErrorAction SilentlyContinue + } + else { + Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value $originalEnableNewLogic + } +} diff --git a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.BlocksDataConstructorInjection.ps1 b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.BlocksDataConstructorInjection.ps1 new file mode 100644 index 000000000000..95ceea389af3 --- /dev/null +++ b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.BlocksDataConstructorInjection.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param() + +$originalEnableNewLogic = $env:AZP_75787_ENABLE_NEW_LOGIC +Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value 'true' + +. $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 +. $PSScriptRoot\..\ArgumentsSanitizer.ps1 + +try { + +$expectedMsg = Get-VstsLocString -Key 'PS_ScriptArgsSanitized' + +# Regression lock for the relaxed validation mode. Each input below uses ONLY +# characters the relaxed allow-list permits (@ { } [ ] plus letters / paths), so +# the character regex leaves it unchanged - yet each is an expression that +# EXECUTES at the dot-source sink (a hashtable value, cast or property getter). +# The AST backstop is what blocks them. If Test-SanitizerArgumentAst is removed +# or weakened these become remote code execution. +$astOnlyInjections = @( + @{ Name = 'Command as hashtable value'; Input = '@{ k = New-Item -Path C:\evil.txt -ItemType File -Force }' }, + @{ Name = 'Get-Content as hashtable value'; Input = '@{ Tag = Get-Content C:\secret.txt }' }, + @{ Name = 'Cast (adsi) as value'; Input = "@{ k = [adsi]'LDAP://attacker' }" }, + @{ Name = '-as conversion as value'; Input = "@{ k = 'C:\victim\file.ps1' -as [System.IO.StreamWriter] }" }, + @{ Name = 'Property getter as value'; Input = '@{ k = [System.Net.Dns]::MachineName }' } +) + +foreach ($test in $astOnlyInjections) { + # 1) Prove the AST is load-bearing: the relaxed CHARACTER allow-list alone + # does NOT alter these inputs, so the regex path would let them through. + $sanitized, $null = Get-SanitizedArguments -InputArgs $test.Input -AllowDataConstructors + Assert-AreEqual -Expected $test.Input -Actual $sanitized ` + -Message "'$($test.Name)' must be carried by the AST backstop, not the character regex (regex altered it unexpectedly)." + + # 2) The AST predicate rejects it. + Assert-AreEqual -Expected $false -Actual (Test-SanitizerArgumentAst $test.Input) ` + -Message "Test-SanitizerArgumentAst should reject '$($test.Name)' [$($test.Input)]" + + # 3) The integrated relaxed path fails closed (throws) with activate on. + try { + Assert-Throws { Protect-ScriptArguments -InputArgs $test.Input -AllowDataConstructors } -MessagePattern $expectedMsg + } + catch { + throw "Expected '$($test.Name)' [$($test.Input)] to be blocked by the relaxed sanitizer, but: $($_.Exception.Message)" + } +} + +# Defense in depth: the dangerous characters the relaxed list still forbids +# ( $ ( ) ; & | ) must remain blocked on the relaxed path as well. +$charInjections = @( + 'test; whoami', + 'test && whoami', + 'echo "$(rm ./x)"', + 'test | whoami', + '@{ k = $(whoami) }' +) + +foreach ($badInput in $charInjections) { + try { + Assert-Throws { Protect-ScriptArguments -InputArgs $badInput -AllowDataConstructors } -MessagePattern $expectedMsg + } + catch { + throw "Expected dangerous characters in [$badInput] to be blocked on the relaxed path, but: $($_.Exception.Message)" + } +} +} +finally { + if ($null -eq $originalEnableNewLogic) { + Remove-Item env:AZP_75787_ENABLE_NEW_LOGIC -ErrorAction SilentlyContinue + } + else { + Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value $originalEnableNewLogic + } +} diff --git a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Passes.ps1 b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Passes.ps1 index 9521cda5c2b3..3057c19c3050 100644 --- a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Passes.ps1 +++ b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Passes.ps1 @@ -1,11 +1,14 @@ [CmdletBinding()] param() +$originalEnableNewLogic = $env:AZP_75787_ENABLE_NEW_LOGIC Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value 'true' . $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 . $PSScriptRoot\..\ArgumentsSanitizer.ps1 +try { + $testSuites = @( @{ Name = 'Handles empty line' @@ -117,3 +120,12 @@ foreach ($test in $testSuites) { } } } +} +finally { + if ($null -eq $originalEnableNewLogic) { + Remove-Item env:AZP_75787_ENABLE_NEW_LOGIC -ErrorAction SilentlyContinue + } + else { + Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value $originalEnableNewLogic + } +} diff --git a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Throws.ps1 b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Throws.ps1 index 3602df85798c..93c1fb8c2bbe 100644 --- a/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Throws.ps1 +++ b/Tasks/Common/Sanitizer/Tests/L0Protect-ScriptArguments.Throws.ps1 @@ -1,11 +1,14 @@ [CmdletBinding()] param() +$originalEnableNewLogic = $env:AZP_75787_ENABLE_NEW_LOGIC Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value 'true' . $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 . $PSScriptRoot\..\ArgumentsSanitizer.ps1 +try { + $testSuites = @( @{ Name = 'If dangerous symbols are present, and FF is on' @@ -66,3 +69,12 @@ foreach ($test in $testSuites) { } } } +} +finally { + if ($null -eq $originalEnableNewLogic) { + Remove-Item env:AZP_75787_ENABLE_NEW_LOGIC -ErrorAction SilentlyContinue + } + else { + Set-Item -Path env:AZP_75787_ENABLE_NEW_LOGIC -Value $originalEnableNewLogic + } +} diff --git a/Tasks/Common/Sanitizer/Tests/L0Test-SanitizerArgumentAst.ps1 b/Tasks/Common/Sanitizer/Tests/L0Test-SanitizerArgumentAst.ps1 new file mode 100644 index 000000000000..a6d53ddb751d --- /dev/null +++ b/Tasks/Common/Sanitizer/Tests/L0Test-SanitizerArgumentAst.ps1 @@ -0,0 +1,58 @@ +[CmdletBinding()] +param() + +. $PSScriptRoot\..\..\..\..\Tests\lib\Initialize-Test.ps1 +. $PSScriptRoot\..\ArgumentsSanitizer.ps1 + +# Unit tests for the structural AST backstop used by the relaxed +# (-AllowDataConstructors) sanitizer path. Returns $true for pure data literals +# and $false for anything that evaluates / executes at the dot-source sink. + +# SAFE: data literals, variables (incl. $env:), quoted strings, bare type/index +# literals and ordinary parameters - none of these execute code. +$safe = @( + '', + ' ', + '-Param1 value1 -Param2 value2', + '@{ Port = 8080 }', + '@{ Owner = "user@contoso.com" }', + "-Tags @{ env = 'prod' }", + '@params', + '[string]', + '[0]', + '-Type [System.String]', + '$env:BUILD_REQUESTEDFOR', + 'value.txt', + '-Path D:\my\path', + '-Flag $true' +) + +# UNSAFE: every item is an expression that evaluates at the sink when it appears +# inside a data constructor (hashtable value, array element) or as a chained +# statement. Verified against the real '.