From 55625f5ea4881a05ced77671ec4af0f87f26d312 Mon Sep 17 00:00:00 2001 From: Afroz Mohammed Date: Thu, 28 May 2026 04:29:41 +0000 Subject: [PATCH 1/2] Invoke PowerShell scripts via AddCommand instead of AddScript to populate automatic variables, added tests --- .../PowerShellFunctionHost.cs | 109 +++-- PowerShell/Tests/LambdaTestHelpers.ps1 | 334 +++++++++++++++ .../Tests/Test-LambdaPSScriptRoot.Tests.ps1 | 399 ++++++++++++++++++ 3 files changed, 816 insertions(+), 26 deletions(-) create mode 100644 PowerShell/Tests/LambdaTestHelpers.ps1 create mode 100644 PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 diff --git a/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs b/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs index 52012c1ca..74d62ee57 100644 --- a/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs +++ b/Libraries/src/Amazon.Lambda.PowerShellHost/PowerShellFunctionHost.cs @@ -33,6 +33,13 @@ public abstract class PowerShellFunctionHost private readonly string _powerShellScriptFileName; private string _powerShellScriptFileContent; + // Cached result of detecting whether a subclass has overridden LoadScript(). + // When overridden, the host must execute the override's returned text instead of + // invoking the file directly, to preserve the pre-5.x contract that override return + // values are always executed. Lazy + short-circuit: only evaluated when a script + // file is present, then cached. + private bool? _isLoadScriptOverriddenCache; + // The PowerShell Object for executing PowerShell code private readonly PowerShell _ps; @@ -155,28 +162,9 @@ private IAsyncResult BeginInvoke(string input, ILambdaContext context) _ps.Runspace?.ResetRunspaceState(); _output.Clear(); - var providedScript = LoadScript(input, context); - - - string executingScript = -@" -Param( - [string]$LambdaInputString, - [Amazon.Lambda.Core.ILambdaContext]$LambdaContext -) - -$LambdaInput = ConvertFrom-Json -InputObject $LambdaInputString - -"; - var isLambda = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LAMBDA_TASK_ROOT")); - var tempFolder = isLambda ? "/tmp" : Path.GetTempPath(); - executingScript += $"{Environment.NewLine}$env:TEMP=\"{tempFolder}\""; - executingScript += $"{Environment.NewLine}$env:TMP=\"{tempFolder}\""; - executingScript += $"{Environment.NewLine}$env:TMPDIR=\"{tempFolder}\"{Environment.NewLine}"; - if(isLambda && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HOME"))) { // Make sure to set HOME directory to avoid issue with using the -Parallel PowerShell feature. This works around @@ -185,18 +173,64 @@ private IAsyncResult BeginInvoke(string input, ILambdaContext context) Environment.SetEnvironmentVariable("HOME", $"{tempFolder}/home"); } - executingScript += providedScript; + // Set environment variables and Lambda input/context as global variables. + // Using $global: makes them visible in all scopes including the user's script. + string setupScript = $@" +$env:TEMP = '{tempFolder}' +$env:TMP = '{tempFolder}' +$env:TMPDIR = '{tempFolder}' +$global:LambdaInputString = $args[0] +$global:LambdaInput = ConvertFrom-Json -InputObject $args[0] +$global:LambdaContext = $args[1] +"; - if (!string.IsNullOrEmpty(PowerShellFunctionName)) + _ps.AddScript(setupScript, useLocalScope: false); + _ps.AddArgument(input); + _ps.AddArgument(context); + var setupResult = _ps.BeginInvoke(); + WaitPowerShellExecution(setupResult); + _ps.Commands.Clear(); + + // Execute the user's script. Use the new AddCommand path only when a file exists + // AND no subclass has overridden LoadScript(). Reflection is short-circuited away + // entirely when no file path was provided (the common S3-fetched-script case). + if (!string.IsNullOrEmpty(_powerShellScriptFileName) + && File.Exists(_powerShellScriptFileName) + && !IsLoadScriptOverridden()) { - executingScript += $"{Environment.NewLine}{PowerShellFunctionName} $LambdaInput $LambdaContext{Environment.NewLine}"; - } + var scriptFullPath = Path.GetFullPath(_powerShellScriptFileName); + if (!string.IsNullOrEmpty(PowerShellFunctionName)) + { + // Dot-source the script so functions it defines are visible in the current scope, + // then call the named function. + _ps.AddScript( + $". '{scriptFullPath}'{Environment.NewLine}{PowerShellFunctionName} $global:LambdaInput $global:LambdaContext", + useLocalScope: false); + } + else + { + // Invoke the script file directly. PowerShell resolves it as ExternalScript, + // populating $PSScriptRoot, $PSCommandPath, $MyInvocation, etc. + var command = new Command(scriptFullPath, isScript: true, useLocalScope: false); + _ps.Commands.AddCommand(command); + } + } + else + { + // Either no file on disk, or a subclass has overridden LoadScript() and we + // must execute its returned text rather than invoking the file directly. + // Automatic variables will be empty in both cases since there is no backing + // file from PowerShell's perspective. + var providedScript = LoadScript(input, context); - _ps.AddScript(executingScript); - _ps.AddParameter("LambdaInputString", input); - _ps.AddParameter("LambdaContext", context); + if (!string.IsNullOrEmpty(PowerShellFunctionName)) + { + providedScript += $"{Environment.NewLine}{PowerShellFunctionName} $global:LambdaInput $global:LambdaContext{Environment.NewLine}"; + } + _ps.AddScript(providedScript, useLocalScope: false); + } return _ps.BeginInvoke(null, _output); } @@ -229,6 +263,29 @@ protected virtual string LoadScript(string input, ILambdaContext context) return _powerShellScriptFileContent; } + // Detects (and caches) whether a subclass has overridden LoadScript(). The override + // is an officially advertised extension point; preserving its pre-5.x semantics is + // required for backward compatibility with subclassers who transform script content. + // Parameter types are specified explicitly so a future overload of LoadScript would + // not throw AmbiguousMatchException, and a null result is handled explicitly so any + // future signature/visibility change does not silently regress every caller onto the + // legacy path. + private bool IsLoadScriptOverridden() + { + if (!_isLoadScriptOverriddenCache.HasValue) + { + var method = GetType().GetMethod( + nameof(LoadScript), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + types: new[] { typeof(string), typeof(ILambdaContext) }, + modifiers: null); + _isLoadScriptOverriddenCache = method != null + && method.DeclaringType != typeof(PowerShellFunctionHost); + } + return _isLoadScriptOverriddenCache.Value; + } + /// /// Waits for the PowerShell execution to be completed /// diff --git a/PowerShell/Tests/LambdaTestHelpers.ps1 b/PowerShell/Tests/LambdaTestHelpers.ps1 new file mode 100644 index 000000000..12e62fb8d --- /dev/null +++ b/PowerShell/Tests/LambdaTestHelpers.ps1 @@ -0,0 +1,334 @@ +# Helper functions for Lambda E2E tests. +# Dot-source this file in Pester BeforeAll blocks. + +function Invoke-AwsCli { + param([string[]]$Arguments) + $allArgs = $Arguments + @('--profile', $script:ProfileName, '--region', $script:Region, '--output', 'json') + $result = & aws @allArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "AWS CLI failed: $result" + } + if ($result) { $result | ConvertFrom-Json } +} + +function New-TestRole { + $trustPolicyFile = Join-Path ([System.IO.Path]::GetTempPath()) "trust-policy-$script:RunId.json" + Set-Content -Path $trustPolicyFile -Value $script:TrustPolicy + + try { + $null = Invoke-AwsCli @('iam', 'create-role', + '--role-name', $script:RoleName, + '--assume-role-policy-document', "file://$trustPolicyFile") + } catch { + if ($_ -match 'EntityAlreadyExists') { } + else { throw } + } + + try { + $null = Invoke-AwsCli @('iam', 'attach-role-policy', + '--role-name', $script:RoleName, + '--policy-arn', $script:PolicyArn) + } catch { + if ($_ -match 'already been attached') { } + else { throw } + } + + Remove-Item $trustPolicyFile -ErrorAction SilentlyContinue + + $roleInfo = Invoke-AwsCli @('iam', 'get-role', '--role-name', $script:RoleName) + return $roleInfo.Role.Arn +} + +function Remove-TestResources { + foreach ($fn in $script:DeployedFunctions) { + try { $null = Invoke-AwsCli @('lambda', 'delete-function', '--function-name', $fn) } catch {} + try { $null = Invoke-AwsCli @('logs', 'delete-log-group', '--log-group-name', "/aws/lambda/$fn") } catch {} + } + + try { $null = Invoke-AwsCli @('iam', 'detach-role-policy', '--role-name', $script:RoleName, '--policy-arn', $script:PolicyArn) } catch {} + try { $null = Invoke-AwsCli @('iam', 'delete-role', '--role-name', $script:RoleName) } catch {} +} + +function New-LambdaPackage { + param( + [string]$HandlerScript, + [string]$StagingDir + ) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + Set-Content -Path (Join-Path $projectDir 'handler.ps1') -Value $HandlerScript + + $bootstrapCs = @' +using Amazon.Lambda.PowerShellHost; + +namespace handler +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base("handler.ps1") + { + } + } +} +'@ + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $csproj = @" + + + net10.0 + true + + + + PreserveNewest + + + + + + + + +"@ + Set-Content -Path (Join-Path $projectDir 'handler.csproj') -Value $csproj + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function New-DynamicScriptPackage { + param([string]$StagingDir) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + $bootstrapCs = @' +using Amazon.Lambda.Core; +using Amazon.Lambda.PowerShellHost; + +namespace DynamicScriptTest +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base() + { + } + + protected override string LoadScript(string input, ILambdaContext context) + { + return @" +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + MyInvocation_Path = $MyInvocation.MyCommand.Path + MyInvocation_CommandType = [string]$MyInvocation.MyCommand.CommandType + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaInputString_Exists = $null -ne $LambdaInputString + TEMP = $env:TEMP + HOME = $env:HOME + LAMBDA_TASK_ROOT = $env:LAMBDA_TASK_ROOT + DynamicMode = $true +} + +[pscustomobject]$result +"; + } + } +} +'@ + + $csproj = @" + + + net10.0 + true + + + + + + + +"@ + + Set-Content -Path (Join-Path $projectDir 'DynamicScriptTest.csproj') -Value $csproj + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function New-LoadScriptOverrideWithFilePackage { + # Issue A regression test: subclass overrides LoadScript() AND a file exists on disk. + # Pre-PR: override always ran. Post-PR without mitigation: file path silently ignores + # the override. Post-PR with mitigation: override runs (text from override is executed). + param([string]$StagingDir) + + $projectDir = Join-Path $StagingDir 'project' + $null = New-Item -ItemType Directory -Path $projectDir -Force + + # The on-disk file says "FromFile" and would expose $PSScriptRoot if it ran. The override + # returns "FromOverride" with empty $PSScriptRoot. The result tells us which one ran. + $originalScript = @' +[pscustomobject]@{ Source = 'FromFile'; PSScriptRoot = $PSScriptRoot } +'@ + Set-Content -Path (Join-Path $projectDir 'handler.ps1') -Value $originalScript + + $bootstrapCs = @' +using Amazon.Lambda.Core; +using Amazon.Lambda.PowerShellHost; + +namespace handler +{ + public class Bootstrap : PowerShellFunctionHost + { + public Bootstrap() : base("handler.ps1") + { + } + + protected override string LoadScript(string input, ILambdaContext context) + { + return @"[pscustomobject]@{ Source = 'FromOverride'; PSScriptRoot = $PSScriptRoot }"; + } + } +} +'@ + Set-Content -Path (Join-Path $projectDir 'Bootstrap.cs') -Value $bootstrapCs + + $csproj = @" + + + net10.0 + true + + + PreserveNewest + + + + + + + +"@ + Set-Content -Path (Join-Path $projectDir 'handler.csproj') -Value $csproj + + $publishDir = Join-Path $StagingDir 'publish' + $publishOutput = & dotnet publish $projectDir -c Release -o $publishDir --framework net10.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet publish failed: $publishOutput" + } + + $outputZip = Join-Path $StagingDir 'package.zip' + Compress-Archive -Path "$publishDir/*" -DestinationPath $outputZip -Force + return $outputZip +} + +function Deploy-TestFunction { + param( + [string]$FunctionName, + [string]$ZipPath, + [string]$RoleArn, + [string]$HandlerOverride, + [hashtable]$EnvironmentVariables + ) + + try { + $null = Invoke-AwsCli @('lambda', 'delete-function', '--function-name', $FunctionName) + } catch { + if ($_ -notmatch 'ResourceNotFoundException') { throw } + } + + $handler = if ($HandlerOverride) { $HandlerOverride } else { 'handler::handler.Bootstrap::ExecuteFunction' } + + $createArgs = @('lambda', 'create-function', + '--function-name', $FunctionName, + '--runtime', $script:Runtime, + '--role', $RoleArn, + '--handler', $handler, + '--zip-file', "fileb://$ZipPath", + '--timeout', '120', + '--memory-size', '512') + + if ($EnvironmentVariables -and $EnvironmentVariables.Count -gt 0) { + $envJson = ($EnvironmentVariables | ConvertTo-Json -Compress) + $createArgs += @('--environment', "{`"Variables`":$envJson}") + } + + $maxRetries = 3 + for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { + try { + $null = Invoke-AwsCli $createArgs + break + } catch { + if ($_ -match 'AccessDeniedException|cannot be assumed' -and $attempt -lt $maxRetries) { + Start-Sleep -Seconds 10 + } else { + throw + } + } + } + + $null = & aws lambda wait function-active-v2 --function-name $FunctionName --profile $script:ProfileName --region $script:Region 2>&1 + if ($LASTEXITCODE -ne 0) { + $null = & aws lambda wait function-active --function-name $FunctionName --profile $script:ProfileName --region $script:Region 2>&1 + } + + $script:DeployedFunctions.Add($FunctionName) +} + +function Invoke-TestFunction { + param( + [string]$FunctionName, + [string]$Payload = '{}' + ) + + $outputFile = Join-Path ([System.IO.Path]::GetTempPath()) "$FunctionName-$(Get-Random).json" + $payloadFile = Join-Path ([System.IO.Path]::GetTempPath()) "$FunctionName-payload-$(Get-Random).json" + Set-Content -Path $payloadFile -Value $Payload + + $invokeResult = & aws lambda invoke ` + --function-name $FunctionName ` + --profile $script:ProfileName ` + --region $script:Region ` + --cli-binary-format raw-in-base64-out ` + --payload "file://$payloadFile" ` + --log-type Tail ` + --output json ` + $outputFile 2>&1 + + Remove-Item $payloadFile -ErrorAction SilentlyContinue + + if ($LASTEXITCODE -ne 0) { + throw "Lambda invoke failed: $invokeResult" + } + + $meta = $invokeResult | ConvertFrom-Json + $response = Get-Content $outputFile -Raw | ConvertFrom-Json + Remove-Item $outputFile -ErrorAction SilentlyContinue + + if ($meta.FunctionError) { + throw "Lambda function error: $($response | ConvertTo-Json -Compress -Depth 3)" + } + + return $response +} diff --git a/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 b/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 new file mode 100644 index 000000000..933793650 --- /dev/null +++ b/PowerShell/Tests/Test-LambdaPSScriptRoot.Tests.ps1 @@ -0,0 +1,399 @@ +#Requires -Modules Pester + +<# +.SYNOPSIS + Pester 5 E2E tests for PowerShell Lambda runtime behavior. + +.EXAMPLE + $container = New-PesterContainer -Path ./Test-LambdaPSScriptRoot.Tests.ps1 -Data @{ + ProfileName = 'myprofile'; Region = 'us-west-2'; Runtime = 'dotnet10' + } + Invoke-Pester -Container $container -Output Detailed +#> + +param( + [Parameter(Mandatory)] + [string]$ProfileName, + + [string]$Region = 'us-west-2', + + [ValidateSet('dotnet8', 'dotnet10')] + [string]$Runtime = 'dotnet10' +) + +BeforeAll { + $script:ProfileName = $ProfileName + $script:Region = $Region + $script:Runtime = $Runtime + $script:RepoRoot = (Resolve-Path "$PSScriptRoot/../../").Path + $script:RunId = [System.Guid]::NewGuid().ToString('N').Substring(0, 8) + $script:RoleName = "ps-lambda-test-$script:RunId" + $script:PolicyArn = 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + $script:DeployedFunctions = [System.Collections.Concurrent.ConcurrentBag[string]]::new() + $script:TrustPolicy = @' +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Service": "lambda.amazonaws.com" }, + "Action": "sts:AssumeRole" + }] +} +'@ + + . "$PSScriptRoot/LambdaTestHelpers.ps1" + + $script:RoleArn = New-TestRole + Start-Sleep -Seconds 10 +} + +AfterAll { + . "$PSScriptRoot/LambdaTestHelpers.ps1" + Remove-TestResources +} + +Describe 'Automatic Variables' { + It 'populates $PSScriptRoot, $PSCommandPath, and $MyInvocation correctly' { + $fnName = "ps-test-auto-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + MyInvocation_Path = $MyInvocation.MyCommand.Path + MyInvocation_Name = $MyInvocation.MyCommand.Name + MyInvocation_CommandType = [string]$MyInvocation.MyCommand.CommandType + MyInvocation_Source = $MyInvocation.MyCommand.Source + LambdaTaskRoot = $env:LAMBDA_TASK_ROOT +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.PSScriptRoot | Should -Be '/var/task' + $r.PSCommandPath | Should -Be '/var/task/handler.ps1' + $r.MyInvocation_Path | Should -Be '/var/task/handler.ps1' + $r.MyInvocation_Name | Should -Be 'handler.ps1' + $r.MyInvocation_CommandType | Should -Be 'ExternalScript' + $r.MyInvocation_Source | Should -Be '/var/task/handler.ps1' + $r.LambdaTaskRoot | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Injected Variables' { + It '$LambdaInput, $LambdaContext, and $LambdaInputString are accessible' { + $fnName = "ps-test-inj-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaContext_FunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + LambdaInputString_Exists = $null -ne $LambdaInputString + LambdaInputString_Type = if ($null -ne $LambdaInputString) { $LambdaInputString.GetType().Name } else { 'null' } +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaContext_FunctionName | Should -Be $fnName + $r.LambdaInputString_Exists | Should -BeTrue + $r.LambdaInputString_Type | Should -Be 'String' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Environment Normalization' { + It '$env:TEMP, TMP, TMPDIR, HOME are set and writable' { + $fnName = "ps-test-env-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$testFile = Join-Path $env:TEMP "write-test-$(Get-Random).txt" +$writable = $false +try { + Set-Content -Path $testFile -Value "test" + if (Test-Path $testFile) { $writable = $true; Remove-Item $testFile -ErrorAction SilentlyContinue } +} catch {} + +$result = [ordered]@{ + TEMP = $env:TEMP + TMP = $env:TMP + TMPDIR = $env:TMPDIR + HOME = $env:HOME + LAMBDA_TASK_ROOT = $env:LAMBDA_TASK_ROOT + TempWritable = $writable +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.TEMP | Should -Be '/tmp' + $r.TMP | Should -Be '/tmp' + $r.TMPDIR | Should -Be '/tmp' + $r.HOME | Should -Be '/tmp/home' + $r.LAMBDA_TASK_ROOT | Should -Be '/var/task' + $r.TempWritable | Should -BeTrue + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'PowerShellFunctionName' { + It 'calls the named function with $LambdaInput and $LambdaContext' { + $fnName = "ps-test-fn-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +function Invoke-LambdaHandler { + param($LambdaInput, $LambdaContext) + $result = [ordered]@{ + FunctionCalled = $true + ReceivedInput = $null -ne $LambdaInput + ReceivedContext = $null -ne $LambdaContext + InputTestKey = $LambdaInput.testKey + ContextFunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + } + [pscustomobject]$result +} +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn ` + -EnvironmentVariables @{ AWS_POWERSHELL_FUNCTION_HANDLER = 'Invoke-LambdaHandler' } + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.FunctionCalled | Should -BeTrue + $r.ReceivedInput | Should -BeTrue + $r.ReceivedContext | Should -BeTrue + $r.InputTestKey | Should -Be 'hello-lambda' + $r.ContextFunctionName | Should -Be $fnName + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Dynamic Script Block (LoadScript override)' { + It 'executes with empty automatic variables and working injected variables' { + $fnName = "ps-test-dyn-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $zip = New-DynamicScriptPackage -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn ` + -HandlerOverride 'DynamicScriptTest::DynamicScriptTest.Bootstrap::ExecuteFunction' + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.DynamicMode | Should -BeTrue + $r.PSScriptRoot | Should -BeNullOrEmpty + $r.PSCommandPath | Should -BeNullOrEmpty + $r.MyInvocation_CommandType | Should -Be 'Script' + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaInputString_Exists | Should -BeTrue + $r.TEMP | Should -Be '/tmp' + $r.HOME | Should -Be '/tmp/home' + $r.LAMBDA_TASK_ROOT | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'LoadScript Override With File On Disk (Issue A regression)' { + It 'runs the override (not the file) when LoadScript is overridden and a file exists' { + $fnName = "ps-test-ovr-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $zip = New-LoadScriptOverrideWithFilePackage -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.Source | Should -Be 'FromOverride' + $r.PSScriptRoot | Should -BeNullOrEmpty + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'ForEach-Object -Parallel' { + It 'propagates $PSScriptRoot and $LambdaInput via $using:' { + $fnName = "ps-test-par-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$mainPSScriptRoot = $PSScriptRoot +$mainLambdaInput = $LambdaInput + +$parallelResults = 1..3 | ForEach-Object -Parallel { + [pscustomobject]@{ + Using_PSScriptRoot = $using:mainPSScriptRoot + LambdaInput_Exists = $null -ne $using:mainLambdaInput + LambdaInput_TestKey = $using:mainLambdaInput.testKey + } +} + +$result = [ordered]@{ + MainPSScriptRoot = $mainPSScriptRoot + ParallelCount = $parallelResults.Count + Parallel_Using_PSScriptRoot = ($parallelResults | ForEach-Object { $_.Using_PSScriptRoot }) -join ',' + Parallel_LambdaInput = ($parallelResults | ForEach-Object { $_.LambdaInput_Exists }) -join ',' + Parallel_TestKey = ($parallelResults | ForEach-Object { $_.LambdaInput_TestKey }) -join ',' +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.ParallelCount | Should -Be 3 + $r.MainPSScriptRoot | Should -Be '/var/task' + ($r.Parallel_Using_PSScriptRoot -split ',')[0] | Should -Be '/var/task' + ($r.Parallel_LambdaInput -split ',')[0] | Should -Be 'True' + ($r.Parallel_TestKey -split ',')[0] | Should -Be 'hello-lambda' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Warm Invocation' { + It 'maintains consistent behavior across two calls on same container' { + $fnName = "ps-test-warm-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$result = [ordered]@{ + PSScriptRoot = $PSScriptRoot + PSCommandPath = $PSCommandPath + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_FunctionName = if ($null -ne $LambdaContext) { $LambdaContext.FunctionName } else { 'null' } + TEMP = $env:TEMP +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + + $r1 = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + Start-Sleep -Seconds 2 + $r2 = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r1.LambdaInput_Exists | Should -BeTrue + $r2.LambdaInput_Exists | Should -BeTrue + $r2.PSScriptRoot | Should -Be $r1.PSScriptRoot + $r2.PSCommandPath | Should -Be $r1.PSCommandPath + $r2.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r2.LambdaContext_FunctionName | Should -Be $fnName + $r2.TEMP | Should -Be '/tmp' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'Module Resolution via $PSScriptRoot' { + It 'uses $PSScriptRoot for path resolution without fallback' { + $fnName = "ps-test-mod-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +$basePath = if ($PSScriptRoot) { $PSScriptRoot } else { $env:LAMBDA_TASK_ROOT } + +$result = [ordered]@{ + BasePath = $basePath + PSScriptRoot = $PSScriptRoot + UsedPSScriptRoot = [bool]$PSScriptRoot + UsedFallback = -not [bool]$PSScriptRoot +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName + + $r.BasePath | Should -Be '/var/task' + $r.PSScriptRoot | Should -Be '/var/task' + $r.UsedPSScriptRoot | Should -BeTrue + $r.UsedFallback | Should -BeFalse + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +Describe 'User Param() Block' { + It 'allows scripts with their own Param() without conflict' { + $fnName = "ps-test-prm-$script:RunId" + $stagingDir = Join-Path ([System.IO.Path]::GetTempPath()) $fnName + $null = New-Item -ItemType Directory -Path $stagingDir -Force + + try { + $handler = @' +Param( + [string]$CustomParam = 'default-value' +) + +$result = [ordered]@{ + CustomParam_Value = $CustomParam + LambdaInput_Exists = $null -ne $LambdaInput + LambdaInput_TestKey = $LambdaInput.testKey + LambdaContext_Exists = $null -ne $LambdaContext + LambdaInputString_Exists = $null -ne $LambdaInputString + PSScriptRoot = $PSScriptRoot +} +[pscustomobject]$result +'@ + $zip = New-LambdaPackage -HandlerScript $handler -StagingDir $stagingDir + Deploy-TestFunction -FunctionName $fnName -ZipPath $zip -RoleArn $script:RoleArn + $r = Invoke-TestFunction -FunctionName $fnName -Payload '{"testKey":"hello-lambda"}' + + $r.CustomParam_Value | Should -Be 'default-value' + $r.LambdaInput_Exists | Should -BeTrue + $r.LambdaInput_TestKey | Should -Be 'hello-lambda' + $r.LambdaContext_Exists | Should -BeTrue + $r.LambdaInputString_Exists | Should -BeTrue + $r.PSScriptRoot | Should -Be '/var/task' + } finally { + Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} From fa4228e943dcfe37e6fdec4542e61ff1e5092d4a Mon Sep 17 00:00:00 2001 From: Sanket Tangade Date: Thu, 25 Jun 2026 10:46:36 -0700 Subject: [PATCH 2/2] Harden GitHub Actions workflows based on zizmor audit - Move untrusted ${{ }} into env vars to prevent script injection - Pin all actions to commit SHAs (no version bumps) - Set top-level permissions: {} with minimal job-level grants - Add concurrency groups and job names - Fix PowerShell/github-script injection in Dockerfile-update workflows - Pin semgrep container image by digest --- .github/workflows/auto-update-Dockerfiles.yml | 36 +++++++++++-------- .github/workflows/aws-ci.yml | 28 +++++++++++---- .../build-lambda-runtime-dockerfiles.yml | 19 ++++++---- .github/workflows/change-file-in-pr.yml | 18 ++++++++-- .github/workflows/closed-issue-message.yml | 16 ++++++--- .github/workflows/create-release-pr.yml | 23 ++++++++---- .../workflows/handle-stale-discussions.yml | 10 ++++-- .../workflows/issue-regression-labeler.yml | 19 ++++++---- .github/workflows/semgrep-analysis.yml | 15 ++++++-- .github/workflows/stale_issues.yml | 13 ++++--- .github/workflows/sync-master-dev.yml | 22 +++++++++--- .github/workflows/update-Dockerfiles.yml | 36 +++++++++++-------- 12 files changed, 180 insertions(+), 75 deletions(-) diff --git a/.github/workflows/auto-update-Dockerfiles.yml b/.github/workflows/auto-update-Dockerfiles.yml index d2fb2b9d6..64095bb1c 100644 --- a/.github/workflows/auto-update-Dockerfiles.yml +++ b/.github/workflows/auto-update-Dockerfiles.yml @@ -1,8 +1,6 @@ name: Auto-Update Lambda Dockerfiles Daily -permissions: - contents: write - pull-requests: write +permissions: {} on: # Run daily at midnight UTC @@ -11,9 +9,17 @@ on: # Allows to run this workflow manually from the Actions tab for testing workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: auto-update: + name: Auto-update Dockerfiles and open PR runs-on: ubuntu-latest + permissions: + contents: write # to push the daily Dockerfile update branch + pull-requests: write # to open the update PR and label it env: NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile" NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile" @@ -39,7 +45,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 8 AMD64 update - No version detected" } @@ -53,7 +59,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 8 ARM64 update - No version detected" } @@ -67,7 +73,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 9 AMD64 update - No version detected" } @@ -81,7 +87,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 9 ARM64 update - No version detected" } @@ -95,7 +101,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 10 AMD64 update - No version detected" } @@ -109,7 +115,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 10 ARM64 update - No version detected" } @@ -123,7 +129,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 11 AMD64 update - No version detected" } @@ -137,7 +143,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 11 ARM64 update - No version detected" } @@ -196,7 +202,7 @@ jobs: - name: Create Pull Request id: pull-request if: ${{ steps.commit-push.outputs.CHANGES_MADE == 'true' }} - uses: repo-sync/pull-request@v2 + uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2 with: source_branch: ${{ steps.commit-push.outputs.BRANCH }} destination_branch: "dev" @@ -226,13 +232,15 @@ jobs: # Add "Release Not Needed" label to the PR - name: Add Release Not Needed label if: ${{ steps.pull-request.outputs.pr_number }} - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pull-request.outputs.pr_number }}, + issue_number: Number(process.env.PR_NUMBER), labels: ['Release Not Needed'] }) diff --git a/.github/workflows/aws-ci.yml b/.github/workflows/aws-ci.yml index dc8491927..83afe378f 100644 --- a/.github/workflows/aws-ci.yml +++ b/.github/workflows/aws-ci.yml @@ -8,12 +8,18 @@ on: - dev - "feature/**" -permissions: - id-token: write +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: run-ci: + name: Run CI runs-on: ubuntu-latest + permissions: + id-token: write # to assume AWS roles via OIDC steps: - name: Configure Load Balancer Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 @@ -24,8 +30,13 @@ jobs: - name: Invoke Load Balancer Lambda id: lambda shell: pwsh + env: + LOAD_BALANCER_LAMBDA_NAME: ${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }} + TEST_RUNNER_ACCOUNT_ROLES: ${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }} + CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} + BRANCH: ${{ github.sha }} run: | - aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Roles": "${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }}", "ProjectName": "${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}", "Branch": "${{ github.sha }}"}' + aws lambda invoke response.json --function-name "$env:LOAD_BALANCER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Roles`": `"$env:TEST_RUNNER_ACCOUNT_ROLES`", `"ProjectName`": `"$env:CODE_BUILD_PROJECT_NAME`", `"Branch`": `"$env:BRANCH`"}" $roleArn=$(cat ./response.json) "roleArn=$($roleArn -replace '"', '')" >> $env:GITHUB_OUTPUT - name: Configure Test Runner Credentials @@ -36,7 +47,7 @@ jobs: aws-region: us-west-2 - name: Run Tests on AWS id: codebuild - uses: aws-actions/aws-codebuild-run-build@v1 + uses: aws-actions/aws-codebuild-run-build@4d15a47425739ac2296ba5e7eee3bdd4bfbdd767 # v1.0.18 with: project-name: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} - name: Configure Test Sweeper Lambda Credentials @@ -49,10 +60,15 @@ jobs: - name: Invoke Test Sweeper Lambda if: always() shell: pwsh + env: + TEST_SWEEPER_LAMBDA_NAME: ${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }} + CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} run: | - aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Tags": "aws-repo=${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}"}' + aws lambda invoke response.json --function-name "$env:TEST_SWEEPER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Tags`": `"aws-repo=$env:CODE_BUILD_PROJECT_NAME`"}" - name: CodeBuild Link shell: pwsh + env: + BUILD_ID: ${{ steps.codebuild.outputs.aws-build-id }} run: | - $buildId = "${{ steps.codebuild.outputs.aws-build-id }}" + $buildId = "$env:BUILD_ID" echo $buildId diff --git a/.github/workflows/build-lambda-runtime-dockerfiles.yml b/.github/workflows/build-lambda-runtime-dockerfiles.yml index 425181175..9f30c61ee 100644 --- a/.github/workflows/build-lambda-runtime-dockerfiles.yml +++ b/.github/workflows/build-lambda-runtime-dockerfiles.yml @@ -8,13 +8,18 @@ on: paths: - "LambdaRuntimeDockerfiles/**" -permissions: - contents: read +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build-runtime-images: name: Build runtime image (${{ matrix.name }}) runs-on: ubuntu-latest + permissions: + contents: read # to check out the repository and build the Dockerfiles strategy: fail-fast: false matrix: @@ -45,16 +50,18 @@ jobs: platform: linux/arm64 steps: - - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 #v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Build ${{ matrix.name }} - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . file: ${{ matrix.dockerfile }} diff --git a/.github/workflows/change-file-in-pr.yml b/.github/workflows/change-file-in-pr.yml index 51a2fb001..7ff26b6a5 100644 --- a/.github/workflows/change-file-in-pr.yml +++ b/.github/workflows/change-file-in-pr.yml @@ -4,24 +4,36 @@ on: pull_request: types: [opened, synchronize, reopened, labeled] +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: check-files-in-directory: if: ${{ !contains(github.event.pull_request.labels.*.name, 'Release Not Needed') && !contains(github.event.pull_request.labels.*.name, 'Release PR') }} name: Change File Included in PR runs-on: ubuntu-latest + permissions: + contents: read # to check out the repository and list changed files steps: - name: Checkout PR code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Get List of Changed Files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 - name: Check for Change File(s) in .autover/changes/ + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | DIRECTORY=".autover/changes/" - if echo "${{ steps.changed-files.outputs.all_changed_files }}" | grep -q "$DIRECTORY"; then + if echo "$ALL_CHANGED_FILES" | grep -q "$DIRECTORY"; then echo "✅ One or more change files in '$DIRECTORY' are included in this PR." else echo "❌ No change files in '$DIRECTORY' are included in this PR." diff --git a/.github/workflows/closed-issue-message.yml b/.github/workflows/closed-issue-message.yml index 3c394caaa..32c8f8706 100644 --- a/.github/workflows/closed-issue-message.yml +++ b/.github/workflows/closed-issue-message.yml @@ -3,18 +3,24 @@ on: issues: types: [closed] -permissions: - issues: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: auto_comment: + name: Comment on closed issue runs-on: ubuntu-latest + permissions: + issues: write # to comment on the closed issue steps: - - uses: aws-actions/closed-issue-message@v2 + - uses: aws-actions/closed-issue-message@10aaf6366131b673a7c8b7742f8b3849f1d44f18 # v2 with: # These inputs are both required repo-token: "${{ secrets.GITHUB_TOKEN }}" message: | - Comments on closed issues are hard for our team to see. - If you need more assistance, please either tag a team member or open a new issue that references this one. + Comments on closed issues are hard for our team to see. + If you need more assistance, please either tag a team member or open a new issue that references this one. If you wish to keep having a conversation with other community members under this issue feel free to do so. diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 02c7fb641..d47599303 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -11,14 +11,19 @@ on: type: string required: false -permissions: - id-token: write - repository-projects: read +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: release-pr: name: Release PR runs-on: ubuntu-latest + permissions: + id-token: write # to assume AWS roles via OIDC + repository-projects: read # to read project metadata when creating the release PR env: INPUT_OVERRIDE_VERSION: ${{ github.event.inputs.OVERRIDE_VERSION }} @@ -97,10 +102,11 @@ jobs: run: autover changelog # Push the release branch up as well as the created tag - name: Push Changes + env: + BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }} run: | - branch=${{ steps.create-release-branch.outputs.BRANCH }} - git push origin $branch - git push origin $branch --tags + git push origin "$BRANCH" + git push origin "$BRANCH" --tags # Get the release name that will be used to create a PR - name: Read Release Name id: read-release-name @@ -117,7 +123,10 @@ jobs: - name: Create Pull Request env: GITHUB_TOKEN: ${{ env.FG_PAT }} + VERSION: ${{ steps.read-release-name.outputs.VERSION }} + CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }} + BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }} run: | gh label create "Release PR" --description "A Release PR that includes versioning and changelog changes" -c "#FF0000" -f - pr_url="$(gh pr create --title "${{ steps.read-release-name.outputs.VERSION }}" --label "Release PR" --body "${{ steps.read-changelog.outputs.CHANGELOG }}" --base dev --head ${{ steps.create-release-branch.outputs.BRANCH }})" + pr_url="$(gh pr create --title "$VERSION" --label "Release PR" --body "$CHANGELOG" --base dev --head "$BRANCH")" diff --git a/.github/workflows/handle-stale-discussions.yml b/.github/workflows/handle-stale-discussions.yml index 534dc6584..e8494e64f 100644 --- a/.github/workflows/handle-stale-discussions.yml +++ b/.github/workflows/handle-stale-discussions.yml @@ -5,14 +5,20 @@ on: discussion_comment: types: [created] +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: handle-stale-discussions: name: Handle stale discussions runs-on: ubuntu-latest permissions: - discussions: write + discussions: write # to mark and close stale discussions steps: - name: Stale discussions action - uses: aws-github-ops/handle-stale-discussions@v1.6.0 + uses: aws-github-ops/handle-stale-discussions@c0beee451a5d33d9c8f048a6d4e7c856b5422544 # v1.6.0 env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/issue-regression-labeler.yml b/.github/workflows/issue-regression-labeler.yml index 7c335bcfe..4f8695a2d 100644 --- a/.github/workflows/issue-regression-labeler.yml +++ b/.github/workflows/issue-regression-labeler.yml @@ -3,16 +3,21 @@ name: issue-regression-label on: issues: types: [opened, edited] +permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true jobs: add-regression-label: + name: Add potential-regression label runs-on: ubuntu-latest permissions: - issues: write + issues: write # to add or remove the potential-regression label steps: - name: Fetch template body id: check_regression - uses: actions/github-script@v8 - env: + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TEMPLATE_BODY: ${{ github.event.issue.body }} with: @@ -24,9 +29,11 @@ jobs: - name: Manage regression label env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IS_REGRESSION: ${{ steps.check_regression.outputs.is_regression }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: | - if [ "${{ steps.check_regression.outputs.is_regression }}" == "true" ]; then - gh issue edit ${{ github.event.issue.number }} --add-label "potential-regression" -R ${{ github.repository }} + if [ "$IS_REGRESSION" == "true" ]; then + gh issue edit "$ISSUE_NUMBER" --add-label "potential-regression" -R "$GITHUB_REPOSITORY" else - gh issue edit ${{ github.event.issue.number }} --remove-label "potential-regression" -R ${{ github.repository }} + gh issue edit "$ISSUE_NUMBER" --remove-label "potential-regression" -R "$GITHUB_REPOSITORY" fi diff --git a/.github/workflows/semgrep-analysis.yml b/.github/workflows/semgrep-analysis.yml index da6e998de..b140b7159 100644 --- a/.github/workflows/semgrep-analysis.yml +++ b/.github/workflows/semgrep-analysis.yml @@ -13,19 +13,28 @@ on: # Manually trigger the workflow workflow_dispatch: +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: semgrep: name: Scan permissions: - security-events: write + contents: read # to check out the repository + security-events: write # to upload SARIF results to the code scanning dashboard runs-on: ubuntu-latest container: - image: returntocorp/semgrep + image: returntocorp/semgrep@sha256:06938c1f365d3f67b8cedd8bc117607ae64253f88a0e768e9da9408548927dd6 # latest # Skip any PR created by dependabot to avoid permission issues if: (github.actor != 'dependabot[bot]') steps: # Fetch project source - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - run: semgrep ci --sarif > semgrep.sarif env: diff --git a/.github/workflows/stale_issues.yml b/.github/workflows/stale_issues.yml index 90932eea5..2e3866233 100644 --- a/.github/workflows/stale_issues.yml +++ b/.github/workflows/stale_issues.yml @@ -5,16 +5,21 @@ on: schedule: - cron: "0 0 * * *" -permissions: - issues: write - pull-requests: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: cleanup: runs-on: ubuntu-latest name: Stale issue job + permissions: + issues: write # to mark and close stale issues + pull-requests: write # to mark and close stale pull requests steps: - - uses: aws-actions/stale-issue-cleanup@v7 + - uses: aws-actions/stale-issue-cleanup@0604f2edf84a3a66bc0dfb4a30eb07814cbdf440 # v7.1.1 with: # Setting messages to an empty string will cause the automation to skip # that category diff --git a/.github/workflows/sync-master-dev.yml b/.github/workflows/sync-master-dev.yml index ae1f6e923..d3f91027f 100644 --- a/.github/workflows/sync-master-dev.yml +++ b/.github/workflows/sync-master-dev.yml @@ -8,9 +8,11 @@ on: pull_request: types: [closed] -permissions: - contents: write - id-token: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: # This job will check if the PR was successfully merged, it's source branch is `releases/next-release` and target branch is `dev`. @@ -23,6 +25,9 @@ jobs: github.event.pull_request.head.ref == 'releases/next-release' && github.event.pull_request.base.ref == 'dev' runs-on: ubuntu-latest + permissions: + contents: write # to merge dev into master and push the release tag + id-token: write # to assume AWS roles via OIDC steps: # Assume an AWS Role that provides access to the Access Token - name: Configure AWS Credentials @@ -96,8 +101,11 @@ jobs: - name: Create GitHub Release env: GITHUB_TOKEN: ${{ env.FG_PAT }} + TAG: ${{ steps.read-tag-name.outputs.TAG }} + VERSION: ${{ steps.read-release-name.outputs.VERSION }} + CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }} run: | - gh release create "${{ steps.read-tag-name.outputs.TAG }}" --title "${{ steps.read-release-name.outputs.VERSION }}" --notes "${{ steps.read-changelog.outputs.CHANGELOG }}" + gh release create "$TAG" --title "$VERSION" --notes "$CHANGELOG" # Delete the `releases/next-release` branch - name: Clean up run: | @@ -118,6 +126,8 @@ jobs: github.event.pull_request.head.ref == 'releases/next-release' && github.event.pull_request.base.ref == 'dev' runs-on: ubuntu-latest + permissions: + contents: write # to delete the release tag and branch steps: # Checkout a full clone of the repo using the deploy key (push runs over SSH) - name: Checkout code @@ -156,9 +166,11 @@ jobs: echo "TAG=$tag" >> $GITHUB_OUTPUT # Delete the tag created by AutoVer and the release branch - name: Clean up + env: + TAG: ${{ steps.read-tag-name.outputs.TAG }} run: | git fetch origin - git push --delete origin ${{ steps.read-tag-name.outputs.TAG }} + git push --delete origin "$TAG" if git ls-remote --exit-code --heads origin releases/next-release > /dev/null; then echo "Branch 'releases/next-release' exists on origin. Deleting..." git push origin --delete releases/next-release diff --git a/.github/workflows/update-Dockerfiles.yml b/.github/workflows/update-Dockerfiles.yml index 7709115fd..283e51f81 100644 --- a/.github/workflows/update-Dockerfiles.yml +++ b/.github/workflows/update-Dockerfiles.yml @@ -1,8 +1,6 @@ name: Update Lambda Dockerfiles -permissions: - contents: write - pull-requests: write +permissions: {} on: # Allows to run this workflow manually from the Actions tab @@ -65,9 +63,17 @@ on: type: string required: true +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: build: + name: Update Dockerfiles and open PR runs-on: ubuntu-latest + permissions: + contents: write # to push the Dockerfile update branch + pull-requests: write # to open the update PR and label it env: NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile" NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile" @@ -92,7 +98,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_8_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_8_AMD64 == 'true' }} - name: Update .NET 8 ARM64 @@ -102,7 +108,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_8_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_8_ARM64 == 'true' }} - name: Update .NET 9 AMD64 @@ -112,7 +118,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_9_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_9_AMD64 == 'true' }} - name: Update .NET 9 ARM64 @@ -122,7 +128,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_9_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_9_ARM64 == 'true' }} - name: Update .NET 10 AMD64 @@ -132,7 +138,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_10_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_10_AMD64 == 'true' }} - name: Update .NET 10 ARM64 @@ -142,7 +148,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_10_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_10_ARM64 == 'true' }} - name: Update .NET 11 AMD64 @@ -152,7 +158,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_11_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_11_AMD64 == 'true' }} - name: Update .NET 11 ARM64 @@ -162,7 +168,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_11_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_11_ARM64 == 'true' }} # Update Dockerfiles if newer version of ASP.NET Core is available @@ -184,7 +190,7 @@ jobs: - name: Pull Request id: pull-request if: ${{ steps.commit-push.outputs.BRANCH }} - uses: repo-sync/pull-request@v2 + uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2 with: source_branch: ${{ steps.commit-push.outputs.BRANCH }} destination_branch: "dev" @@ -211,13 +217,15 @@ jobs: # Add "Release Not Needed" label to the PR - name: Add Release Not Needed label if: ${{ steps.pull-request.outputs.pr_number }} - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pull-request.outputs.pr_number }}, + issue_number: Number(process.env.PR_NUMBER), labels: ['Release Not Needed'] })