fix(security): handle PowerShell EncodedCommand abbreviations (#270)
Some checks are pending
Build and Test / test (push) Waiting to run
Build and Test / build (win-arm64) (push) Blocked by required conditions
Build and Test / build (win-x64) (push) Blocked by required conditions
Build and Test / build-msix (ARM64, win-arm64) (push) Blocked by required conditions
Build and Test / build-msix (x64, win-x64) (push) Blocked by required conditions
Build and Test / build-extension (arm64) (push) Blocked by required conditions
Build and Test / build-extension (x64) (push) Blocked by required conditions
Build and Test / release (push) Blocked by required conditions

Handles PowerShell EncodedCommand aliases and separator forms, including -e, so approval evaluation remains fail-closed.\n\nValidation: local ARM64 build passed; Shared tests 1319 passed / 20 skipped; Tray tests 466 passed; remote CI green.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
github-actions[bot] 2026-05-07 18:12:46 -04:00 committed by GitHub
parent 6e8a9d72ad
commit 56d956d723
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 157 additions and 20 deletions

View File

@ -135,8 +135,26 @@ internal static class ExecShellWrapperParser
for (var i = 1; i < tokens.Length; i++)
{
var option = tokens[i];
if (option.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-c", StringComparison.OrdinalIgnoreCase))
// Check for inline separator form first: -flag:value or -flag=value
var sepIdx = IndexOfFlagSeparator(option);
if (sepIdx > 0)
{
var flagPart = option[..sepIdx];
var valuePart = option[(sepIdx + 1)..];
if (IsCommandFlag(flagPart))
{
return string.IsNullOrWhiteSpace(valuePart)
? ("", shell, "Shell wrapper payload was empty")
: (valuePart, shell, null);
}
if (IsEncodedCommandFlag(flagPart))
return DecodeEncodedPayload(valuePart, shell);
}
if (IsCommandFlag(option))
{
var payload = string.Join(" ", tokens, i + 1, tokens.Length - i - 1).Trim();
return string.IsNullOrWhiteSpace(payload)
@ -144,32 +162,68 @@ internal static class ExecShellWrapperParser
: (payload, shell, null);
}
if (option.Equals("-EncodedCommand", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-enc", StringComparison.OrdinalIgnoreCase) ||
option.Equals("-ec", StringComparison.OrdinalIgnoreCase))
if (IsEncodedCommandFlag(option))
{
var encoded = i + 1 < tokens.Length ? tokens[i + 1] : null;
if (string.IsNullOrWhiteSpace(encoded))
return ("", shell, "Shell wrapper payload was empty");
try
{
var bytes = Convert.FromBase64String(encoded);
var payload = Encoding.Unicode.GetString(bytes).Trim();
return string.IsNullOrWhiteSpace(payload)
? ("", shell, "EncodedCommand decoded to an empty payload")
: (payload, shell, null);
}
catch (FormatException)
{
return ("", shell, "EncodedCommand could not be decoded");
}
return DecodeEncodedPayload(encoded, shell);
}
}
return default;
}
// Returns the index of the first ':' or '=' in a flag token (after the leading '-').
private static int IndexOfFlagSeparator(string token)
{
for (var i = 1; i < token.Length; i++)
{
if (token[i] == ':' || token[i] == '=')
return i;
}
return -1;
}
// Matches -Command and -c (documented PowerShell -Command aliases).
private static bool IsCommandFlag(string flag) =>
flag.Equals("-Command", StringComparison.OrdinalIgnoreCase) ||
flag.Equals("-c", StringComparison.OrdinalIgnoreCase);
// Matches -e/-ec aliases and all unique prefix abbreviations of -EncodedCommand.
// Windows PowerShell accepts -e as EncodedCommand despite the apparent ambiguity with
// -ExecutionPolicy, so the parser must fail closed and decode it.
private static bool IsEncodedCommandFlag(string flag)
{
if (flag.Equals("-e", StringComparison.OrdinalIgnoreCase))
return true;
if (flag.Equals("-ec", StringComparison.OrdinalIgnoreCase))
return true;
const string fullFlag = "-encodedcommand";
return flag.Length >= 3 && // minimum: -en
flag.Length <= fullFlag.Length &&
fullFlag.StartsWith(flag, StringComparison.OrdinalIgnoreCase);
}
private static (string? Payload, string? Shell, string? Error) DecodeEncodedPayload(string? encoded, string shell)
{
if (string.IsNullOrWhiteSpace(encoded))
return ("", shell, "Shell wrapper payload was empty");
try
{
var bytes = Convert.FromBase64String(encoded);
var payload = Encoding.Unicode.GetString(bytes).Trim();
return string.IsNullOrWhiteSpace(payload)
? ("", shell, "EncodedCommand decoded to an empty payload")
: (payload, shell, null);
}
catch (FormatException)
{
return ("", shell, "EncodedCommand could not be decoded");
}
}
private static List<string> SplitTopLevelCommands(string command)
{
var parts = new List<string>();

View File

@ -138,6 +138,89 @@ public class ExecShellWrapperParserTests
Assert.Contains(result.Targets, t => t.Command.Contains("Remove-Item"));
}
// All unique prefix abbreviations of -EncodedCommand beyond -enc/-ec.
// Windows PowerShell also accepts -e as EncodedCommand, so include it to
// keep the shell-wrapper parser fail-closed.
[Theory]
[InlineData("-e")]
[InlineData("-en")]
[InlineData("-enco")]
[InlineData("-encod")]
[InlineData("-encode")]
[InlineData("-encoded")]
[InlineData("-encodedc")]
[InlineData("-encodedco")]
[InlineData("-encodedcom")]
[InlineData("-encodedcomm")]
[InlineData("-encodedcomma")]
[InlineData("-encodedcomman")]
[InlineData("-encodedcommand")]
public void Expand_Powershell_EncodedCommand_PrefixAbbreviation_Decodes(string flag)
{
var payload = "Get-ChildItem C:\\";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload));
var result = Expand($"powershell {flag} {encoded}");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Get-ChildItem"));
}
// Inline separator forms: -enc:value and -enc=value
[Theory]
[InlineData("-enc")]
[InlineData("-EncodedCommand")]
[InlineData("-encodedcommand")]
public void Expand_Powershell_EncodedCommand_ColonSeparator_Decodes(string flagBase)
{
var payload = "Invoke-Something";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload));
var result = Expand($"powershell {flagBase}:{encoded}");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Invoke-Something"));
}
[Theory]
[InlineData("-enc")]
[InlineData("-EncodedCommand")]
public void Expand_Powershell_EncodedCommand_EqualsSeparator_Decodes(string flagBase)
{
var payload = "Write-Host hi";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload));
var result = Expand($"powershell {flagBase}={encoded}");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Write-Host"));
}
// -Command separator forms
[Theory]
[InlineData("-Command")]
[InlineData("-c")]
public void Expand_Powershell_Command_ColonSeparator_ExtractsPayload(string flagBase)
{
var result = Expand($"powershell {flagBase}:Get-Process");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Get-Process"));
}
[Theory]
[InlineData("-Command")]
[InlineData("-c")]
public void Expand_Powershell_Command_EqualsSeparator_ExtractsPayload(string flagBase)
{
var result = Expand($"powershell {flagBase}=Get-Date");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Get-Date"));
}
[Fact]
public void Expand_Powershell_SingleE_DecodesEncodedCommand()
{
var payload = "Get-ChildItem";
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(payload));
var result = Expand($"powershell -e {encoded}");
Assert.Null(result.Error);
Assert.Contains(result.Targets, t => t.Command.Contains("Get-ChildItem"));
}
[Fact]
public void Expand_Powershell_EncodedCommand_EmptyPayload_ReturnsError()
{