mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Harden PowerShell exec-policy classification
This commit is contained in:
@@ -1,16 +1,13 @@
|
||||
use codex_shell_command::bash::extract_bash_command;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::powershell::extract_powershell_command;
|
||||
|
||||
const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__";
|
||||
const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__";
|
||||
|
||||
/// Canonicalize command argv for approval-cache matching.
|
||||
///
|
||||
/// This keeps approval decisions stable across wrapper-path differences (for
|
||||
/// example `/bin/bash -lc` vs `bash -lc`) and across shell wrapper tools while
|
||||
/// preserving exact script text for complex scripts where we cannot safely
|
||||
/// recover a tokenized command sequence.
|
||||
/// Bash word-only scripts retain their historical inner-command identity. For
|
||||
/// PowerShell, the executable, wrapper flags, and exact script are all part of
|
||||
/// the key because each affects the authorization boundary.
|
||||
pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<String> {
|
||||
if let Some(commands) = parse_shell_lc_plain_commands(command)
|
||||
&& let [single_command] = commands.as_slice()
|
||||
@@ -27,13 +24,6 @@ pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec<Strin
|
||||
];
|
||||
}
|
||||
|
||||
if let Some((_shell, script)) = extract_powershell_command(command) {
|
||||
return vec![
|
||||
CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string(),
|
||||
script.to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
command.to_vec()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
use super::canonicalize_command_for_approval;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec<String> {
|
||||
vec![
|
||||
executable.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
command_flag.to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_word_only_shell_scripts_to_inner_command() {
|
||||
@@ -54,30 +64,60 @@ fn canonicalizes_heredoc_scripts_to_stable_script_key() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_powershell_wrappers_to_stable_script_key() {
|
||||
let script = "Write-Host hi";
|
||||
let command_a = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
];
|
||||
let command_b = vec![
|
||||
"powershell".to_string(),
|
||||
"-Command".to_string(),
|
||||
script.to_string(),
|
||||
fn preserves_exact_powershell_approval_identity() {
|
||||
let base = powershell("C:/workspace/powershell.exe", "-Command", "Write-Host hi");
|
||||
let variants = [
|
||||
powershell("powershell.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/other/powershell.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/pwsh.exe", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/powershell.exe", "-c", "Write-Host hi"),
|
||||
powershell(
|
||||
"C:/workspace/powershell.exe",
|
||||
"-Command",
|
||||
"Write-Host changed",
|
||||
),
|
||||
powershell("C:/workspace/powershell.exe.", "-Command", "Write-Host hi"),
|
||||
powershell("C:/workspace/powershell.exe ", "-Command", "Write-Host hi"),
|
||||
powershell(
|
||||
r"\\?\C:\workspace\powershell.exe.",
|
||||
"-Command",
|
||||
"Write-Host hi",
|
||||
),
|
||||
powershell(
|
||||
r"\\.\UNC\server\share\powershell.exe ",
|
||||
"-Command",
|
||||
"Write-Host hi",
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
vec![
|
||||
"__codex_powershell_script__".to_string(),
|
||||
script.to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_command_for_approval(&command_a),
|
||||
canonicalize_command_for_approval(&command_b)
|
||||
let mut commands = vec![base];
|
||||
commands.extend(variants);
|
||||
let keys: HashSet<_> = commands
|
||||
.iter()
|
||||
.map(|command| canonicalize_command_for_approval(command))
|
||||
.collect();
|
||||
|
||||
assert_eq!(keys.len(), commands.len());
|
||||
for command in commands {
|
||||
assert_eq!(canonicalize_command_for_approval(&command), command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_identity_does_not_collide_with_raw_marker_argv() {
|
||||
let powershell = vec![
|
||||
"powershell.exe".to_string(),
|
||||
"-Command".to_string(),
|
||||
"Write-Host hi".to_string(),
|
||||
];
|
||||
let mut marker_argv = vec!["__codex_powershell_script__".to_string()];
|
||||
marker_argv.extend(powershell.iter().cloned());
|
||||
|
||||
assert_eq!(canonicalize_command_for_approval(&powershell), powershell);
|
||||
assert_eq!(canonicalize_command_for_approval(&marker_argv), marker_argv);
|
||||
assert_ne!(
|
||||
canonicalize_command_for_approval(&powershell),
|
||||
canonicalize_command_for_approval(&marker_argv)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use shlex::try_join as shlex_try_join;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "exec_policy_powershell.rs"]
|
||||
mod powershell_policy;
|
||||
|
||||
const PROMPT_CONFLICT_REASON: &str =
|
||||
"approval required by policy, but AskForApproval is set to Never";
|
||||
const REJECT_SANDBOX_APPROVAL_REASON: &str =
|
||||
@@ -279,11 +283,31 @@ impl ExecPolicyManager {
|
||||
prefix_rule,
|
||||
} = req;
|
||||
let exec_policy = self.current();
|
||||
#[cfg(windows)]
|
||||
let parsed_powershell = match powershell_policy::prepare(command) {
|
||||
Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => {
|
||||
return requirement;
|
||||
}
|
||||
Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => Some(parsed),
|
||||
None => None,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let exec_policy_commands = if let Some(parsed) = parsed_powershell.as_ref() {
|
||||
ExecPolicyCommands {
|
||||
commands: parsed.commands().to_vec(),
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
}
|
||||
} else {
|
||||
commands_for_exec_policy(command)
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let exec_policy_commands = commands_for_exec_policy(command);
|
||||
let ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing,
|
||||
command_origin,
|
||||
} = commands_for_exec_policy(command);
|
||||
} = exec_policy_commands;
|
||||
// Keep heredoc prefix parsing for rule evaluation so existing
|
||||
// allow/prompt/forbidden rules still apply, but avoid auto-derived
|
||||
// amendments when only the heredoc fallback parser matched.
|
||||
@@ -767,20 +791,6 @@ fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands {
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(commands) =
|
||||
codex_shell_command::powershell::parse_powershell_command_into_plain_commands(command)
|
||||
&& !commands.is_empty()
|
||||
{
|
||||
return ExecPolicyCommands {
|
||||
commands,
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(single_command) = parse_shell_lc_single_command_prefix(command) {
|
||||
return ExecPolicyCommands {
|
||||
commands: vec![single_command],
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use codex_shell_command::powershell::PowerShellExecPolicyParse;
|
||||
|
||||
use super::render_shlex_command;
|
||||
use crate::tools::sandboxing::ExecApprovalRequirement;
|
||||
|
||||
pub(super) enum PreparedPowerShell {
|
||||
Terminal(ExecApprovalRequirement),
|
||||
Parsed(ParsedPowerShell),
|
||||
}
|
||||
|
||||
pub(super) struct ParsedPowerShell {
|
||||
commands: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
pub(super) fn prepare(command: &[String]) -> Option<PreparedPowerShell> {
|
||||
match codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)? {
|
||||
PowerShellExecPolicyParse::TrustedRuntime {
|
||||
commands: Some(commands),
|
||||
} if !commands.is_empty() => {
|
||||
Some(PreparedPowerShell::Parsed(ParsedPowerShell { commands }))
|
||||
}
|
||||
PowerShellExecPolicyParse::TrustedRuntime { .. } => Some(forbidden(
|
||||
command,
|
||||
"the PowerShell script could not be inspected",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime {
|
||||
commands: Some(commands),
|
||||
} if !commands.is_empty() => Some(forbidden(
|
||||
command,
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
)),
|
||||
PowerShellExecPolicyParse::UntrustedRuntime { .. } => Some(forbidden(
|
||||
command,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn forbidden(command: &[String], reason: &str) -> PreparedPowerShell {
|
||||
PreparedPowerShell::Terminal(ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{}` rejected: {reason}", render_shlex_command(command)),
|
||||
})
|
||||
}
|
||||
|
||||
impl ParsedPowerShell {
|
||||
pub(super) fn commands(&self) -> &[Vec<String>] {
|
||||
&self.commands
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::*;
|
||||
|
||||
fn trusted_windows_powershell() -> String {
|
||||
codex_shell_command::powershell::try_find_powershell_executable_blocking()
|
||||
.expect("Windows PowerShell must be installed")
|
||||
.as_path()
|
||||
.to_str()
|
||||
.expect("the Windows PowerShell path must be valid UTF-8")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval {
|
||||
AskForApproval::Granular(GranularApprovalConfig {
|
||||
rules,
|
||||
sandbox_approval,
|
||||
skill_approval: false,
|
||||
request_permissions: false,
|
||||
mcp_elicitations: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_every_noninspectable_or_untrusted_powershell_state() {
|
||||
let cases = [
|
||||
(
|
||||
"untrusted parsed runtime",
|
||||
vec_str(&["powershell.EXE.CmD", "-Command", "echo allowed"]),
|
||||
Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#),
|
||||
"the PowerShell runtime is not a protected system executable",
|
||||
),
|
||||
(
|
||||
"trusted empty body",
|
||||
vec![
|
||||
trusted_windows_powershell(),
|
||||
"-Command".into(),
|
||||
String::new(),
|
||||
],
|
||||
None,
|
||||
"the PowerShell script could not be inspected",
|
||||
),
|
||||
(
|
||||
"untrusted opaque body",
|
||||
vec_str(&[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"echo blocked",
|
||||
]),
|
||||
None,
|
||||
"an untrusted PowerShell wrapper could not be inspected with the protected system parser",
|
||||
),
|
||||
];
|
||||
let policies = [
|
||||
AskForApproval::Never,
|
||||
AskForApproval::OnRequest,
|
||||
AskForApproval::UnlessTrusted,
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ false, /*sandbox_approval*/ true),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ false),
|
||||
granular(/*rules*/ true, /*sandbox_approval*/ true),
|
||||
];
|
||||
let profiles = [
|
||||
PermissionProfile::Disabled,
|
||||
PermissionProfile::read_only(),
|
||||
PermissionProfile::default(),
|
||||
PermissionProfile::External {
|
||||
network: NetworkSandboxPolicy::Restricted,
|
||||
},
|
||||
];
|
||||
|
||||
for (name, command, policy_src, reason) in cases {
|
||||
let rendered = render_shlex_command(&command);
|
||||
for approval_policy in policies {
|
||||
for permission_profile in &profiles {
|
||||
let requirement =
|
||||
exec_approval_requirement_for_command(ExecApprovalRequirementScenario {
|
||||
policy_src: policy_src.map(str::to_owned),
|
||||
command: command.clone(),
|
||||
approval_policy,
|
||||
permission_profile: permission_profile.clone(),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
prefix_rule: None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!("`{rendered}` rejected: {reason}"),
|
||||
},
|
||||
"{} with {approval_policy:?} and {permission_profile:?}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ use toml::Value as TomlValue;
|
||||
#[path = "exec_policy_windows_tests.rs"]
|
||||
mod windows_tests;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "exec_policy_powershell_tests.rs"]
|
||||
mod powershell_tests;
|
||||
|
||||
fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack {
|
||||
let dot_codex_folder =
|
||||
AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder");
|
||||
@@ -2007,12 +2011,13 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() {
|
||||
])));
|
||||
let (pwsh_approval_reason, expected_req) = if cfg!(windows) {
|
||||
(
|
||||
r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean
|
||||
that no sandbox is present, so anything that is not "provably
|
||||
safe" should require approval."#,
|
||||
ExecApprovalRequirement::NeedsApproval {
|
||||
reason: None,
|
||||
proposed_execpolicy_amendment: expected_amendment.clone(),
|
||||
"On Windows, an untrusted PowerShell wrapper that cannot be inspected is rejected.",
|
||||
ExecApprovalRequirement::Forbidden {
|
||||
reason: format!(
|
||||
"`{}` rejected: an untrusted PowerShell wrapper could not be inspected with \
|
||||
the protected system parser",
|
||||
render_shlex_command(&sneaky_command)
|
||||
),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -58,25 +58,6 @@ async fn evaluates_powershell_inner_commands_against_allow_rules() {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_for_exec_policy_parses_powershell_shell_wrapper() {
|
||||
let command = vec![
|
||||
TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
"echo blocked".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
commands_for_exec_policy(&command),
|
||||
ExecPolicyCommands {
|
||||
commands: vec![vec!["echo".to_string(), "blocked".to_string()]],
|
||||
used_complex_parsing: false,
|
||||
command_origin: ExecPolicyCommandOrigin::PowerShell,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_for_exec_policy_keeps_bare_powershell_alias_opaque() {
|
||||
let command = vec![
|
||||
|
||||
@@ -9,8 +9,6 @@ use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
#[cfg(windows)]
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -109,7 +107,7 @@ fn enable_unified_exec(config: &mut codex_core::config::Config) {
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn unified_exec_workspace_powershell_path_requires_approval_before_execution() -> Result<()> {
|
||||
async fn unified_exec_workspace_powershell_path_is_rejected_before_execution() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(enable_unified_exec);
|
||||
let test = builder.build(&server).await?;
|
||||
@@ -122,7 +120,7 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
|
||||
.expect("the test workspace path must be valid UTF-8")
|
||||
.to_string();
|
||||
|
||||
let call_id = "unified-exec-workspace-powershell-requires-approval";
|
||||
let call_id = "unified-exec-workspace-powershell-rejected";
|
||||
let args = json!({
|
||||
"shell": fake_powershell.clone(),
|
||||
"cmd": "Get-Location",
|
||||
@@ -137,7 +135,7 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
let results_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-workspace-powershell-1", "done"),
|
||||
@@ -164,22 +162,20 @@ async fn unified_exec_workspace_powershell_path_requires_approval_before_executi
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let EventMsg::ExecApprovalRequest(approval) = event else {
|
||||
panic!("workspace PowerShell must request approval before execution, got {event:?}");
|
||||
};
|
||||
assert_eq!(approval.command.first(), Some(&fake_powershell));
|
||||
assert!(
|
||||
matches!(event, EventMsg::TurnComplete(_)),
|
||||
"workspace PowerShell must be rejected before approval or execution, got {event:?}"
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Denied,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
let output_item = results_mock.single_request().function_call_output(call_id);
|
||||
let output = output_item
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("function call output should include a string output payload");
|
||||
assert!(
|
||||
output.contains("the PowerShell runtime is not a protected system executable"),
|
||||
"unexpected output: {output}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user