Harden PowerShell exec-policy classification

This commit is contained in:
Chris Bookholt
2026-07-02 19:40:51 -07:00
parent 9f9db61ddf
commit c1ff6221c1
11 changed files with 435 additions and 105 deletions
@@ -5,6 +5,7 @@ pub mod is_safe_command;
#[cfg(windows)]
pub(crate) mod windows_safe_commands;
pub(crate) use powershell_parser::TrustedPowerShellFlavor;
pub(crate) use powershell_parser::is_trusted_powershell_parser_executable;
#[cfg(all(test, windows))]
pub(crate) use powershell_parser::trusted_standard_pwsh_invocation_path;
#[cfg(all(test, windows))]
@@ -120,6 +120,10 @@ fn trusted_powershell_parser_executable(executable: &str) -> Option<PathBuf> {
}
}
pub(crate) fn is_trusted_powershell_parser_executable(executable: &str) -> bool {
trusted_powershell_parser_executable(executable).is_some()
}
/// Returns the authoritative Windows PowerShell invocation path only when the production trust
/// resolver accepts that exact known-folder-derived path.
#[cfg(all(test, windows))]
+167 -10
View File
@@ -1,12 +1,9 @@
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::command_safety::TrustedPowerShellFlavor;
use crate::command_safety::is_trusted_powershell_parser_executable;
use crate::command_safety::try_parse_powershell_ast_commands;
use crate::command_safety::try_parse_powershell_ast_commands_with_trusted_flavor;
use crate::shell_detect::ShellType;
use crate::shell_detect::detect_shell_type;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
@@ -48,12 +45,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
}
let shell = &command[0];
if !matches!(
detect_shell_type(PathBuf::from(shell)),
Some(ShellType::PowerShell)
) {
return None;
}
powershell_flavor_for_executable(shell)?;
// Find the first occurrence of -Command (accept common short alias -c as well)
let mut i = 1usize;
@@ -64,6 +56,9 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> {
return None;
}
if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") {
if i + 2 != command.len() {
return None;
}
let script = &command[i + 1];
return Some((shell, script));
}
@@ -91,6 +86,51 @@ pub enum PowerShellFlavor {
PowerShell7,
}
/// Result of inspecting a PowerShell wrapper for exec-policy evaluation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PowerShellExecPolicyParse {
/// The runtime wrapper is the same protected executable used for parsing.
TrustedRuntime { commands: Option<Vec<Vec<String>>> },
/// A protected parser inspected the body, but the runtime wrapper remains untrusted.
UntrustedRuntime { commands: Option<Vec<Vec<String>>> },
}
/// Inspects a PowerShell wrapper without spawning its model-selected runtime.
pub fn parse_powershell_command_for_exec_policy(
command: &[String],
) -> Option<PowerShellExecPolicyParse> {
let executable = command.first()?;
let flavor = powershell_flavor_for_executable(executable)?;
let commands = extract_powershell_command(command)
.and_then(|(_, script)| parse_powershell_script_with_trusted_parser(flavor, script));
if is_trusted_powershell_parser_executable(executable) {
Some(PowerShellExecPolicyParse::TrustedRuntime { commands })
} else {
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands })
}
}
fn powershell_flavor_for_executable(executable: &str) -> Option<PowerShellFlavor> {
let mut executable_name = std::path::Path::new(executable).file_name()?.to_str()?;
#[cfg(windows)]
{
executable_name = executable_name.trim_end_matches([' ', '.']);
}
loop {
let executable_stem = std::path::Path::new(executable_name)
.file_stem()?
.to_str()?;
if executable_stem.eq_ignore_ascii_case("powershell") {
return Some(PowerShellFlavor::WindowsPowerShell);
} else if executable_stem.eq_ignore_ascii_case("pwsh") {
return Some(PowerShellFlavor::PowerShell7);
} else if executable_stem == executable_name {
return None;
}
executable_name = executable_stem;
}
}
/// Parses a PowerShell script without using the runtime wrapper as the parser executable.
///
/// The selected parser comes from the authoritative Windows System or Program Files known folder.
@@ -176,14 +216,20 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
#[cfg(test)]
mod tests {
#[cfg(windows)]
use super::PowerShellExecPolicyParse;
#[cfg(windows)]
use super::PowerShellFlavor;
use super::UTF8_OUTPUT_PREFIX;
use super::extract_powershell_command;
#[cfg(windows)]
use super::parse_powershell_command_for_exec_policy;
#[cfg(windows)]
use super::parse_powershell_command_into_plain_commands;
#[cfg(windows)]
use super::parse_powershell_script_with_trusted_parser;
#[cfg(windows)]
use super::powershell_flavor_for_executable;
use super::prefix_powershell_script_with_utf8;
#[cfg(windows)]
@@ -241,6 +287,18 @@ mod tests {
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[test]
fn rejects_arguments_after_powershell_script() {
let command = vec![
"powershell.exe".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
"unexpected".to_string(),
];
assert_eq!(extract_powershell_command(&command), None);
}
#[test]
fn prefixes_powershell_command_with_best_effort_utf8() {
let cmd = vec![
@@ -330,4 +388,103 @@ mod tests {
);
}
}
#[cfg(windows)]
#[test]
fn exec_policy_parse_preserves_runtime_trust_and_aliases() {
assert_eq!(
powershell_flavor_for_executable(r"\\.\UNC\server\share\pwsh.EXE.bAt "),
Some(PowerShellFlavor::PowerShell7)
);
let trusted = trusted_windows_powershell_executable();
let parsed = Some(vec![vec!["echo".to_string(), "classified".to_string()]]);
let cases = [
(trusted, true),
("powershell.exe".to_string(), false),
("powershell.EXE.CmD".to_string(), false),
(r".\tools\powershell.exe".to_string(), false),
(r"C:\workspace\powershell.exe".to_string(), false),
("powershell.exe.".to_string(), false),
("powershell.exe ".to_string(), false),
(r"\\?\C:\workspace\powershell.exe.".to_string(), false),
(r"\\.\C:\workspace\powershell.exe ".to_string(), false),
(r"\\.\UNC\server\share\powershell.exe ".to_string(), false),
];
for (executable, trusted) in cases {
let result = parse_powershell_command_for_exec_policy(&[
executable.clone(),
"-NoProfile".to_string(),
"-Command".to_string(),
"echo classified".to_string(),
]);
let expected = if trusted {
PowerShellExecPolicyParse::TrustedRuntime {
commands: parsed.clone(),
}
} else {
PowerShellExecPolicyParse::UntrustedRuntime {
commands: parsed.clone(),
}
};
assert_eq!(result, Some(expected), "runtime {executable:?}");
}
}
#[cfg(windows)]
#[test]
fn ordinary_trailing_aliases_launch_but_remain_untrusted() {
let trusted = trusted_windows_powershell_executable();
for executable in [format!("{trusted}."), format!("{trusted} ")] {
assert!(super::is_powershellish_executable_available(
std::path::Path::new(&executable)
));
assert!(matches!(
parse_powershell_command_for_exec_policy(&[
executable,
"-Command".to_string(),
"echo launchable".to_string(),
]),
Some(PowerShellExecPolicyParse::UntrustedRuntime { commands: Some(_) })
));
}
}
#[cfg(windows)]
#[test]
fn exec_policy_parse_marks_unsupported_bodies_opaque() {
let command = |executable: &str, args: &[&str]| {
std::iter::once(executable.to_string())
.chain(args.iter().map(|arg| (*arg).to_string()))
.collect::<Vec<_>>()
};
let cases = [
(
command(
&trusted_windows_powershell_executable(),
&["-Command", "param([string]$path) echo blocked"],
),
PowerShellExecPolicyParse::TrustedRuntime { commands: None },
),
(
command(
"powershell.exe",
&["-NonInteractive", "-Command", "echo blocked"],
),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
),
(
command("powershell.exe", &["-Command", "echo blocked", "trailing"]),
PowerShellExecPolicyParse::UntrustedRuntime { commands: None },
),
];
for (command, expected) in cases {
assert_eq!(
parse_powershell_command_for_exec_policy(&command),
Some(expected),
"command {command:?}"
);
}
}
}