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
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:
parent
6e8a9d72ad
commit
56d956d723
@ -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>();
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user