const SAFE_VERB_PREFIXES: &[&str] = &[
"Get-",
"Test-",
"Measure-",
"Select-",
"Where-",
"ForEach-",
"Sort-",
"Group-",
"Compare-",
"Format-",
"Write-",
"ConvertTo-",
"ConvertFrom-",
"Join-",
"Split-",
"Resolve-",
"Convert-",
"Find-",
"Search-",
"Show-",
"Wait-",
"Watch-",
"Trace-",
"Debug-",
"Assert-",
"Confirm-",
];
const BLOCKED_VERB_PREFIXES: &[&str] = &[
"Remove-",
"Clear-",
"Stop-",
"Set-",
"New-",
"Start-",
"Invoke-",
"Enable-",
"Disable-",
"Uninstall-",
"Restart-",
"Suspend-",
"Resume-",
"Reset-",
"Revoke-",
"Deny-",
"Block-",
"Unblock-",
"Disconnect-",
"Dismount-",
"Unregister-",
"Import-",
"Export-",
"Out-",
"Read-",
];
const SAFE_CMDLET_EXCEPTIONS: &[&str] = &[
"Set-Location",
"New-Guid",
"New-TimeSpan",
"Start-Sleep",
"Clear-Host",
"Out-String",
"Out-Null",
"Out-Host",
"Out-Default",
"Import-Csv",
"Import-Clixml",
"Export-Csv",
"Export-Clixml",
"Export-FormatData",
"Read-Host",
];
const PS_ONLY_DANGEROUS_ALIASES: &[(&str, &str)] = &[
("iex", "Invoke-Expression"),
("ihy", "Invoke-History"),
("icm", "Invoke-Command"),
("irm", "Invoke-RestMethod"),
("iwr", "Invoke-WebRequest"),
("del", "Remove-Item"),
("ri", "Remove-Item"),
("ni", "New-Item"),
("ndr", "New-PSDrive"),
("mi", "Move-Item"),
("cpi", "Copy-Item"),
("si", "Set-Item"),
("sp", "Set-ItemProperty"),
("sc", "Set-Content"),
("spps", "Stop-Process"),
("saps", "Start-Process"),
("sajb", "Start-Job"),
];
const POSIX_COLLIDING_DANGEROUS_ALIASES: &[(&str, &str)] = &[
("rm", "Remove-Item"),
("rmdir", "Remove-Item"),
("kill", "Stop-Process"),
("mv", "Move-Item"),
("cp", "Copy-Item"),
("start", "Start-Process"),
];
const POWERSHELL_BUILTINS: &[&str] = &[
"gci", "gi", "gp", "gpv", "gc", "gl", "gm", "gps", "gsv", "gwmi",
"fl", "ft", "fw", "oh", "sls", "measure", "%", "?", "cls", "dir", "type", "more", "diff", "sleep", "sl", "cd", "chdir", "echo", "h", "history", "pwd", "sort", "where", "foreach",
];
pub(super) fn check_powershell_cmdlet(base: &str) -> Option<bool> {
if POWERSHELL_BUILTINS
.iter()
.any(|&b| b.eq_ignore_ascii_case(base))
{
return Some(true);
}
if let Some(&(_, target)) = PS_ONLY_DANGEROUS_ALIASES
.iter()
.find(|&&(alias, _)| alias.eq_ignore_ascii_case(base))
{
if is_safe_exception(target) {
return Some(true);
}
return Some(false);
}
if cfg!(windows) {
if let Some(&(_, target)) = POSIX_COLLIDING_DANGEROUS_ALIASES
.iter()
.find(|&&(alias, _)| alias.eq_ignore_ascii_case(base))
{
if is_safe_exception(target) {
return Some(true);
}
return Some(false);
}
}
if !looks_like_cmdlet(base) {
return None;
}
if is_safe_exception(base) {
return Some(true);
}
if is_safe_verb(base) {
return Some(true);
}
if is_blocked_verb(base) {
return Some(false);
}
None
}
fn looks_like_cmdlet(s: &str) -> bool {
let Some(hyphen_pos) = s.find('-') else {
return false;
};
if hyphen_pos == 0 || hyphen_pos == s.len() - 1 {
return false;
}
let prefix = &s[..hyphen_pos];
let suffix = &s[hyphen_pos + 1..];
prefix.chars().all(|c| c.is_ascii_alphabetic())
&& suffix
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
}
fn is_safe_verb(base: &str) -> bool {
SAFE_VERB_PREFIXES
.iter()
.any(|prefix| starts_with_ignore_case(base, prefix))
}
fn is_blocked_verb(base: &str) -> bool {
BLOCKED_VERB_PREFIXES
.iter()
.any(|prefix| starts_with_ignore_case(base, prefix))
}
fn is_safe_exception(base: &str) -> bool {
SAFE_CMDLET_EXCEPTIONS
.iter()
.any(|&exc| exc.eq_ignore_ascii_case(base))
}
fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_cmdlets_pass() {
for cmd in [
"Get-Date",
"Get-ChildItem",
"Test-Path",
"Measure-Object",
"Select-Object",
"Format-Table",
"Write-Output",
"ConvertTo-Json",
"ConvertFrom-Csv",
"Compare-Object",
"Sort-Object",
"Where-Object",
"ForEach-Object",
"Join-Path",
"Split-Path",
"Resolve-Path",
"Find-Module",
] {
assert_eq!(
check_powershell_cmdlet(cmd),
Some(true),
"{cmd} should be safe"
);
}
}
#[test]
fn blocked_cmdlets_rejected() {
for cmd in [
"Remove-Item",
"Clear-Content",
"Stop-Process",
"Set-Content",
"New-Item",
"Start-Process",
"Invoke-Expression",
"Invoke-Command",
"Enable-PSRemoting",
"Disable-PSBreakpoint",
"Restart-Service",
"Import-Module",
"Out-File",
"Export-ModuleMember",
"New-Object",
"Read-Eval",
] {
assert_eq!(
check_powershell_cmdlet(cmd),
Some(false),
"{cmd} should be blocked"
);
}
}
#[test]
fn safe_exceptions_override_blocked_verb() {
for cmd in [
"Set-Location",
"New-Guid",
"New-TimeSpan",
"Start-Sleep",
"Clear-Host",
"Out-String",
"Out-Null",
"Out-Host",
"Import-Csv",
"Import-Clixml",
"Export-Csv",
"Read-Host",
] {
assert_eq!(
check_powershell_cmdlet(cmd),
Some(true),
"{cmd} should be safe (exception)"
);
}
}
#[test]
fn new_object_is_blocked() {
assert_eq!(check_powershell_cmdlet("New-Object"), Some(false));
assert_eq!(check_powershell_cmdlet("new-object"), Some(false));
}
#[test]
fn ps_only_dangerous_aliases_blocked_on_all_platforms() {
for alias in [
"iex", "icm", "irm", "iwr", "del", "ri", "ni", "si", "sp", "sc",
] {
assert_eq!(
check_powershell_cmdlet(alias),
Some(false),
"{alias} is a PS-only dangerous alias, must be blocked on all platforms"
);
}
}
#[test]
#[cfg(windows)]
fn posix_colliding_aliases_blocked_on_windows() {
for alias in ["rm", "rmdir", "kill", "mv", "cp", "start"] {
assert_eq!(
check_powershell_cmdlet(alias),
Some(false),
"{alias} is a POSIX-colliding alias, must be blocked on Windows"
);
}
}
#[test]
#[cfg(not(windows))]
fn posix_colliding_aliases_fall_through_on_unix() {
for alias in ["rm", "rmdir", "kill", "mv", "cp"] {
assert_eq!(
check_powershell_cmdlet(alias),
None,
"{alias} should fall through to standard allowlist on Unix"
);
}
}
#[test]
fn sl_is_safe_builtin() {
assert_eq!(check_powershell_cmdlet("sl"), Some(true));
}
#[test]
fn non_cmdlets_return_none() {
for cmd in ["git", "cargo", "ls", "npm", "docker", "curl"] {
assert_eq!(check_powershell_cmdlet(cmd), None, "{cmd} is not a cmdlet");
}
}
#[test]
fn powershell_builtins_pass() {
for cmd in [
"gci", "gc", "dir", "type", "sls", "%", "?", "cls", "sleep", "measure", "echo", "pwd",
"sort", "where", "foreach",
] {
assert_eq!(
check_powershell_cmdlet(cmd),
Some(true),
"{cmd} is a PS builtin/alias"
);
}
}
#[test]
fn ogv_not_a_builtin_anymore() {
assert_eq!(check_powershell_cmdlet("ogv"), None);
assert_eq!(check_powershell_cmdlet("Out-GridView"), Some(false));
}
#[test]
fn tee_not_a_ps_builtin_anymore() {
assert_eq!(check_powershell_cmdlet("tee"), None);
}
#[test]
fn case_insensitive_cmdlet_matching() {
assert_eq!(check_powershell_cmdlet("get-date"), Some(true));
assert_eq!(check_powershell_cmdlet("GET-DATE"), Some(true));
assert_eq!(check_powershell_cmdlet("remove-item"), Some(false));
assert_eq!(check_powershell_cmdlet("INVOKE-Expression"), Some(false));
assert_eq!(check_powershell_cmdlet("set-location"), Some(true));
assert_eq!(check_powershell_cmdlet("NEW-GUID"), Some(true));
}
#[test]
fn case_insensitive_ps_only_alias() {
assert_eq!(check_powershell_cmdlet("IEX"), Some(false));
assert_eq!(check_powershell_cmdlet("Iex"), Some(false));
assert_eq!(check_powershell_cmdlet("DEL"), Some(false));
}
#[test]
fn edge_cases() {
assert_eq!(check_powershell_cmdlet("-"), None);
assert_eq!(check_powershell_cmdlet("Get-"), None);
assert_eq!(check_powershell_cmdlet("-Item"), None);
assert_eq!(check_powershell_cmdlet(""), None);
assert_eq!(check_powershell_cmdlet("Get"), None);
}
#[test]
fn unknown_verb_returns_none() {
assert_eq!(check_powershell_cmdlet("Custom-Thing"), None);
assert_eq!(check_powershell_cmdlet("Foo-Bar"), None);
}
#[test]
fn import_module_blocked_import_csv_safe() {
assert_eq!(check_powershell_cmdlet("Import-Module"), Some(false));
assert_eq!(check_powershell_cmdlet("Import-Csv"), Some(true));
assert_eq!(check_powershell_cmdlet("Import-Clixml"), Some(true));
}
#[test]
fn out_file_blocked_out_string_safe() {
assert_eq!(check_powershell_cmdlet("Out-File"), Some(false));
assert_eq!(check_powershell_cmdlet("Out-String"), Some(true));
assert_eq!(check_powershell_cmdlet("Out-Null"), Some(true));
assert_eq!(check_powershell_cmdlet("Out-Host"), Some(true));
assert_eq!(check_powershell_cmdlet("Out-GridView"), Some(false));
}
#[test]
fn start_sleep_safe_start_process_blocked() {
assert_eq!(check_powershell_cmdlet("Start-Sleep"), Some(true));
assert_eq!(check_powershell_cmdlet("Start-Process"), Some(false));
assert_eq!(check_powershell_cmdlet("Start-Job"), Some(false));
}
#[test]
fn read_host_is_safe_exception() {
assert_eq!(check_powershell_cmdlet("Read-Host"), Some(true));
}
#[test]
fn read_verb_blocked_by_default_except_read_host() {
assert_eq!(check_powershell_cmdlet("Read-Host"), Some(true));
assert_eq!(check_powershell_cmdlet("Read-Eval"), Some(false));
}
}