use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
#[derive(Debug, Clone, Default)]
pub struct CommandConfig {
pub safe_flags: Option<Vec<String>>,
pub allow_all_flags: bool,
pub regex: Option<Regex>,
}
static CMDLET_ALLOWLIST: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("get-childitem");
set.insert("get-content");
set.insert("get-item");
set.insert("get-itemproperty");
set.insert("test-path");
set.insert("resolve-path");
set.insert("get-filehash");
set.insert("get-acl");
set.insert("set-location");
set.insert("push-location");
set.insert("pop-location");
set.insert("select-string");
set.insert("convertto-json");
set.insert("convertfrom-json");
set.insert("convertto-csv");
set.insert("convertfrom-csv");
set.insert("convertto-xml");
set.insert("convertto-html");
set.insert("format-hex");
set.insert("get-member");
set.insert("get-unique");
set.insert("compare-object");
set.insert("join-string");
set.insert("get-random");
set.insert("convert-path");
set.insert("join-path");
set.insert("split-path");
set.insert("get-hotfix");
set.insert("get-itempropertyvalue");
set.insert("get-psprovider");
set.insert("get-process");
set.insert("get-service");
set.insert("get-computerinfo");
set.insert("get-host");
set.insert("get-date");
set.insert("get-location");
set.insert("get-psdrive");
set.insert("get-module");
set.insert("get-alias");
set.insert("get-history");
set.insert("get-culture");
set.insert("get-uiculture");
set.insert("get-timezone");
set.insert("get-uptime");
set.insert("write-output");
set.insert("write-host");
set.insert("start-sleep");
set.insert("format-table");
set.insert("format-list");
set.insert("format-wide");
set.insert("format-custom");
set.insert("measure-object");
set.insert("select-object");
set.insert("sort-object");
set.insert("group-object");
set.insert("where-object");
set.insert("out-string");
set.insert("out-host");
set.insert("get-netadapter");
set.insert("get-netipaddress");
set.insert("get-netipconfiguration");
set.insert("get-netroute");
set.insert("get-dnsclientcache");
set.insert("get-dnsclient");
set.insert("get-eventlog");
set.insert("get-winevent");
set.insert("get-cimclass");
set.insert("git");
set.insert("gh");
set.insert("docker");
set.insert("dotnet");
set.insert("ipconfig");
set.insert("netstat");
set.insert("systeminfo");
set.insert("tasklist");
set.insert("where.exe");
set.insert("hostname");
set.insert("whoami");
set.insert("ver");
set.insert("arp");
set.insert("route");
set.insert("getmac");
set.insert("file");
set.insert("tree");
set.insert("findstr");
set
});
static SAFE_OUTPUT_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("out-null");
set
});
static PIPELINE_TAIL_CMDLETS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("format-table");
set.insert("format-list");
set.insert("format-wide");
set.insert("format-custom");
set.insert("measure-object");
set.insert("select-object");
set.insert("sort-object");
set.insert("group-object");
set.insert("where-object");
set.insert("out-string");
set.insert("out-host");
set
});
static SAFE_EXTERNAL_EXES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("where.exe");
set
});
static WINDOWS_PATHEXT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.(exe|cmd|bat|com)$").unwrap());
static COMMON_ALIASES: Lazy<std::collections::HashMap<&'static str, &'static str>> =
Lazy::new(|| {
let mut map = std::collections::HashMap::new();
map.insert("rm", "remove-item");
map.insert("del", "remove-item");
map.insert("ri", "remove-item");
map.insert("rd", "remove-item");
map.insert("rmdir", "remove-item");
map.insert("gc", "get-content");
map.insert("cat", "get-content");
map.insert("type", "get-content");
map.insert("gci", "get-childitem");
map.insert("dir", "get-childitem");
map.insert("ls", "get-childitem");
map.insert("ni", "new-item");
map.insert("mkdir", "new-item");
map.insert("cp", "copy-item");
map.insert("copy", "copy-item");
map.insert("cpi", "copy-item");
map.insert("mv", "move-item");
map.insert("move", "move-item");
map.insert("mi", "move-item");
map.insert("ren", "rename-item");
map.insert("rni", "rename-item");
map.insert("si", "set-item");
map.insert("sc", "set-content");
map.insert("set", "set-content");
map.insert("ac", "add-content");
map.insert("cd", "set-location");
map.insert("sl", "set-location");
map.insert("chdir", "set-location");
map.insert("pushd", "push-location");
map.insert("popd", "pop-location");
map.insert("select", "select-string");
map.insert("find", "findstr");
map.insert("echo", "write-output");
map.insert("write", "write-output");
map.insert("gal", "get-alias");
map.insert("gh", "get-help");
map.insert("gm", "get-member");
map.insert("gps", "get-process");
map.insert("gsv", "get-service");
map.insert("fl", "format-list");
map.insert("ft", "format-table");
map.insert("fw", "format-wide");
map.insert("sort", "sort-object");
map.insert("group", "group-object");
map.insert("where", "where-object");
map.insert("foreach", "foreach-object");
map.insert("%", "foreach-object");
map.insert("?", "where-object");
map
});
static DOTNET_READ_ONLY_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("--version");
set.insert("--info");
set.insert("--list-runtimes");
set.insert("--list-sdks");
set
});
static DANGEROUS_GIT_GLOBAL_FLAGS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("-c");
set.insert("-C");
set.insert("--exec-path");
set.insert("--config-env");
set.insert("--git-dir");
set.insert("--work-tree");
set.insert("--attr-source");
set
});
static GIT_GLOBAL_FLAGS_WITH_VALUES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
let mut set = HashSet::new();
set.insert("-c");
set.insert("-C");
set.insert("--exec-path");
set.insert("--config-env");
set.insert("--git-dir");
set.insert("--work-tree");
set.insert("--namespace");
set.insert("--super-prefix");
set.insert("--shallow-file");
set
});
static DANGEROUS_GIT_SHORT_FLAGS_ATTACHED: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["-c", "-C"]);
pub fn resolve_to_canonical(name: &str) -> String {
let mut lower = name.to_lowercase();
if !lower.contains('\\') && !lower.contains('/') {
lower = WINDOWS_PATHEXT.replace(&lower, "").to_string();
}
if let Some(alias) = COMMON_ALIASES.get(lower.as_str()) {
return alias.to_string();
}
lower
}
pub fn is_cwd_changing_cmdlet(name: &str) -> bool {
let canonical = resolve_to_canonical(name);
matches!(
canonical.as_str(),
"set-location" | "push-location" | "pop-location" | "new-psdrive"
)
}
pub fn is_safe_output_command(name: &str) -> bool {
let canonical = resolve_to_canonical(name);
SAFE_OUTPUT_CMDLETS.contains(canonical.as_str())
}
pub fn is_allowlisted_pipeline_tail(name: &str) -> bool {
let canonical = resolve_to_canonical(name);
PIPELINE_TAIL_CMDLETS.contains(canonical.as_str())
}
pub fn has_sync_security_concerns(command: &str) -> bool {
let trimmed = command.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.contains("$(") {
return true;
}
if Regex::new(r"(?:^|[^\w.])@\w+").unwrap().is_match(trimmed) {
return true;
}
if Regex::new(r"\.\w+\s*\(").unwrap().is_match(trimmed) {
return true;
}
if Regex::new(r"\$\w+\s*[+\-*/]?=").unwrap().is_match(trimmed) {
return true;
}
if trimmed.contains("--%") {
return true;
}
if trimmed.contains("\\\\") {
return true;
}
if trimmed.contains("//") && !trimmed.contains("://") {
return true;
}
if trimmed.contains("::") {
return true;
}
false
}
pub fn is_read_only_command(command: &str) -> bool {
let trimmed_command = command.trim();
if trimmed_command.is_empty() {
return false;
}
if has_sync_security_concerns(trimmed_command) {
return false;
}
let first_word = trimmed_command.split_whitespace().next().unwrap_or("");
let canonical = resolve_to_canonical(first_word);
if !CMDLET_ALLOWLIST.contains(canonical.as_str()) {
return false;
}
let write_patterns = [
"set-content",
"add-content",
"remove-item",
"clear-content",
"new-item",
"copy-item",
"move-item",
"rename-item",
"set-item",
"out-file",
"tee-object",
"export-csv",
"export-clixml",
];
for pattern in write_patterns {
let cmd_pattern = format!(" {}", pattern);
if trimmed_command.to_lowercase().contains(&cmd_pattern) {
return false;
}
}
if trimmed_command.contains(">")
&& !trimmed_command.contains("> $null")
&& !trimmed_command.contains(">|")
{
return false;
}
true
}
pub fn arg_leaks_value(arg: &str) -> bool {
if arg.contains('$') || arg.contains("@{") || arg.contains("$(") || arg.contains("@(") {
return true;
}
false
}
fn validate_flags(args: &[String], safe_flags: &[&str]) -> bool {
for arg in args {
if !arg.starts_with('-') && !arg.starts_with('/') {
continue;
}
let flag_name = if arg.starts_with('-') || arg.starts_with('/') {
if let Some(colon_idx) = arg.find(':') {
&arg[1..colon_idx]
} else {
&arg[1..]
}
} else {
arg
};
let flag_lower = flag_name.to_lowercase();
let is_safe = safe_flags.iter().any(|f| f.to_lowercase() == flag_lower);
if !is_safe {
return false;
}
}
true
}
pub fn is_git_safe(args: &[String]) -> bool {
if args.is_empty() {
return true;
}
for arg in args {
if arg.contains('$') {
return false;
}
}
let mut idx = 0;
while idx < args.len() {
let arg = &args[idx];
if !arg.starts_with('-') {
break;
}
for short_flag in DANGEROUS_GIT_SHORT_FLAGS_ATTACHED.iter() {
if arg.len() > short_flag.len() && arg.starts_with(short_flag) {
if *short_flag == "-C" && arg.chars().nth(short_flag.len()) != Some('-') {
return false;
}
}
}
let flag_name = if let Some(eq_idx) = arg.find('=') {
&arg[..eq_idx]
} else {
arg
};
if DANGEROUS_GIT_GLOBAL_FLAGS.contains(flag_name) {
return false;
}
if !arg.contains('=') && GIT_GLOBAL_FLAGS_WITH_VALUES.contains(flag_name) {
idx += 2;
} else {
idx += 1;
}
}
if idx >= args.len() {
return true;
}
let subcmd = args[idx].to_lowercase();
let read_only_git = [
"status",
"diff",
"log",
"show",
"blame",
"branch",
"tag",
"stash",
"remote",
"reflog",
"ls-files",
"ls-tree",
"rev-parse",
"show-ref",
"name-rev",
"describe",
"shortlog",
"diff-tree",
"cat-file",
"verify-pack",
"fsck",
"check-ignore",
"checkout-index",
];
if !read_only_git.contains(&subcmd.as_str()) {
return false;
}
let flag_args: Vec<String> = args[idx + 1..].to_vec();
let safe_flags = vec![
"--name-only",
"--oneline",
"-q",
"--quiet",
"-s",
"--short",
"--stat",
];
validate_flags(&flag_args, &safe_flags)
}
pub fn is_docker_safe(args: &[String]) -> bool {
if args.is_empty() {
return true;
}
for arg in args {
if arg.contains('$') {
return false;
}
}
let subcmd = args[0].to_lowercase();
let read_only_docker = [
"ps",
"images",
"ls",
"inspect",
"logs",
"top",
"stats",
"port",
"network",
"volume",
"container",
"image",
"version",
"info",
];
if !read_only_docker.contains(&subcmd.as_str()) {
return false;
}
true
}
pub fn is_dotnet_safe(args: &[String]) -> bool {
if args.is_empty() {
return false;
}
for arg in args {
if !DOTNET_READ_ONLY_FLAGS.contains(arg.to_lowercase().as_str()) {
return false;
}
}
true
}
pub fn is_external_command_safe(command: &str, args: &[String]) -> bool {
match command.to_lowercase().as_str() {
"git" => is_git_safe(args),
"docker" => is_docker_safe(args),
"dotnet" => is_dotnet_safe(args),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_to_canonical() {
assert_eq!(resolve_to_canonical("rm"), "remove-item");
assert_eq!(resolve_to_canonical("gc"), "get-content");
assert_eq!(resolve_to_canonical("cd"), "set-location");
assert_eq!(resolve_to_canonical("git.exe"), "git");
}
#[test]
fn test_is_cwd_changing_cmdlet() {
assert!(is_cwd_changing_cmdlet("set-location"));
assert!(is_cwd_changing_cmdlet("cd"));
assert!(!is_cwd_changing_cmdlet("get-content"));
}
#[test]
fn test_has_sync_security_concerns() {
assert!(has_sync_security_concerns("$(whoami)"));
assert!(has_sync_security_concerns("$var = 1"));
assert!(has_sync_security_concerns(".Method()"));
assert!(!has_sync_security_concerns("Write-Host $env:SECRET"));
assert!(!has_sync_security_concerns("Get-Content file.txt"));
}
#[test]
fn test_is_read_only_command() {
assert!(is_read_only_command("Get-Content test.txt"));
assert!(is_read_only_command("Get-ChildItem"));
assert!(is_read_only_command("Select-String pattern *.txt"));
assert!(!is_read_only_command("Set-Content test.txt 'hello'"));
assert!(!is_read_only_command("Remove-Item test.txt"));
}
#[test]
fn test_git_safe() {
assert!(is_git_safe(&["status".to_string()]));
assert!(is_git_safe(&["log".to_string()]));
assert!(is_git_safe(&["diff".to_string()]));
assert!(!is_git_safe(&["push".to_string()]));
assert!(!is_git_safe(&["reset".to_string(), "--hard".to_string()]));
}
}