use super::super::RiskClass;
use super::destructive::*;
use super::lexer::*;
use super::tables::*;
pub(crate) fn shell_severity(risk: RiskClass) -> u8 {
match risk {
RiskClass::ReadOnly => 0,
RiskClass::ShellMutation => 1,
RiskClass::Process => 2,
RiskClass::Network | RiskClass::SystemMutation => 3,
RiskClass::Destructive => 4,
_ => 1,
}
}
pub(crate) fn shell_max(a: RiskClass, b: RiskClass) -> RiskClass {
if shell_severity(a) >= shell_severity(b) {
a
} else {
b
}
}
pub(crate) fn classify_head(head: &str, segment: &[String]) -> RiskClass {
if NETWORK_BINARIES.contains(&head) {
return RiskClass::Network;
}
if head == "git" {
let sub = segment
.iter()
.skip(1)
.find(|t| !t.starts_with('-'))
.map(|s| s.as_str());
return match sub {
Some(s) if GIT_READ_ONLY.contains(&s) => RiskClass::ReadOnly,
Some("clone") | Some("fetch") | Some("pull") | Some("push") => RiskClass::Network,
_ => RiskClass::ShellMutation,
};
}
if matches!(head, "awk" | "gawk" | "mawk" | "nawk") {
return classify_awk(segment);
}
if head == "find" {
return classify_find(segment);
}
if head == "sort" && sort_writes_file(segment) {
return RiskClass::ShellMutation;
}
if head == "yq" && segment_has_flag(segment, 'i', "inplace") {
return RiskClass::ShellMutation;
}
if head == "date" && segment_has_flag(segment, 's', "set") {
return RiskClass::ShellMutation;
}
if system_install_shape(head, segment) {
return RiskClass::SystemMutation;
}
if PROCESS_BINARIES.contains(&head) {
return RiskClass::Process;
}
if READ_ONLY_BINARIES.contains(&head) {
return RiskClass::ReadOnly;
}
let ps_head = head.to_ascii_lowercase();
if matches!(
ps_head.as_str(),
"invoke-webrequest"
| "invoke-restmethod"
| "iwr"
| "irm"
| "invoke-command"
| "icm"
| "enter-pssession"
| "new-pssession"
) {
return RiskClass::Network;
}
if matches!(
ps_head.as_str(),
"invoke-expression" | "iex" | "invoke-item" | "ii" | "start-process" | "saps" | "start"
) {
return RiskClass::Process;
}
if PS_READ_ONLY_CMDLETS.contains(&ps_head.as_str()) {
return RiskClass::ReadOnly;
}
RiskClass::ShellMutation
}
pub(crate) fn system_install_shape(head: &str, segment: &[String]) -> bool {
let head = head.to_ascii_lowercase();
let sub = segment
.iter()
.skip(1)
.find(|t| !t.starts_with('-'))
.map(|s| s.to_ascii_lowercase());
let sub = sub.as_deref();
let global_flag = segment.iter().skip(1).any(|t| {
t == "--global" || (t.starts_with('-') && !t.starts_with("--") && t[1..].contains('g'))
});
const INSTALL_VERBS: &[&str] = &[
"install",
"add",
"uninstall",
"remove",
"update",
"upgrade",
"link",
];
match head.as_str() {
"npm" | "pnpm" | "bun" => sub.is_some_and(|s| INSTALL_VERBS.contains(&s)) && global_flag,
"yarn" => {
sub == Some("global")
|| (sub.is_some_and(|s| INSTALL_VERBS.contains(&s)) && global_flag)
},
"cargo" => matches!(sub, Some("install" | "uninstall")),
"go" => sub == Some("install"),
"gem" => matches!(sub, Some("install" | "uninstall" | "update")),
"pipx" => true,
"pip" | "pip2" | "pip3" => matches!(sub, Some("install" | "uninstall")),
"dotnet" => {
sub == Some("tool")
&& segment
.iter()
.skip(1)
.filter(|t| !t.starts_with('-'))
.nth(1)
.is_some_and(|s| {
matches!(
s.to_ascii_lowercase().as_str(),
"install" | "uninstall" | "update"
)
})
},
"brew" | "apt" | "apt-get" | "dnf" | "yum" | "zypper" | "apk" | "snap" | "flatpak"
| "choco" | "scoop" | "winget" | "port" => matches!(
sub,
Some(
"install"
| "uninstall"
| "remove"
| "purge"
| "upgrade"
| "update"
| "add"
| "dist-upgrade"
)
),
"pacman" => segment
.iter()
.skip(1)
.any(|t| t.starts_with("-S") || t.starts_with("-R") || t.starts_with("-U")),
_ => false,
}
}
pub(crate) fn classify_awk(segment: &[String]) -> RiskClass {
for tok in segment.iter().skip(1) {
let t = tok.as_str();
if t.starts_with("-F")
|| t.starts_with("-v")
|| t.starts_with("--field-separator")
|| t.starts_with("--assign")
{
continue;
}
if t == "-i"
|| (t.starts_with("-i") && t.len() > 2)
|| t == "-f"
|| (t.starts_with("-f") && t.len() > 2)
|| t.starts_with("--include")
|| t.starts_with("--file")
{
return RiskClass::ShellMutation;
}
if t.contains('>') {
return RiskClass::ShellMutation;
}
if t.contains('|') || t.contains("system") {
return RiskClass::Process;
}
}
RiskClass::ReadOnly
}
pub(crate) fn classify_find(segment: &[String]) -> RiskClass {
let mut worst = RiskClass::ReadOnly;
for tok in segment.iter().skip(1) {
match tok.as_str() {
"-exec" | "-execdir" | "-ok" | "-okdir" => return RiskClass::Process,
"-delete" | "-fprint" | "-fprint0" | "-fprintf" | "-fls" => {
worst = shell_max(worst, RiskClass::ShellMutation);
},
_ => {},
}
}
worst
}
pub(crate) fn sort_writes_file(segment: &[String]) -> bool {
segment.iter().skip(1).any(|t| {
let t = t.as_str();
if t == "--output" || t.starts_with("--output=") {
return true;
}
match t.strip_prefix('-') {
Some(short) if !t.starts_with("--") && !short.is_empty() => {
short.starts_with('o') || short.ends_with('o')
},
_ => false,
}
})
}
pub(crate) fn classify_shell_command(command: &str) -> RiskClass {
classify_shell_command_depth(command, 0)
}
pub(crate) fn classify_shell_command_depth(command: &str, depth: u8) -> RiskClass {
if contains_destructive_pattern(command) {
return RiskClass::Destructive;
}
let mut worst = RiskClass::ReadOnly;
let split = split_command(command);
for segment in &split.segments {
worst = shell_max(worst, classify_segment(&tokenize(segment)));
if depth < MAX_SUBST_DEPTH {
for body in extract_substitutions(segment) {
worst = shell_max(worst, classify_shell_command_depth(&body, depth + 1));
}
} else if !extract_substitutions(segment).is_empty() {
worst = shell_max(worst, RiskClass::ShellMutation);
}
}
for hd in &split.heredocs {
if !hd.expands {
continue;
}
let bodies = extract_substitutions_quote_blind(&hd.body);
if depth < MAX_SUBST_DEPTH {
for body in &bodies {
worst = shell_max(worst, classify_shell_command_depth(body, depth + 1));
}
} else if !bodies.is_empty() {
worst = shell_max(worst, RiskClass::ShellMutation);
}
if bodies.is_empty()
&& (hd.body.contains("$(") || hd.body.contains('`') || hd.body.contains("<("))
{
worst = shell_max(worst, RiskClass::ShellMutation);
}
}
worst
}
pub(crate) fn classify_segment(tokens: &[String]) -> RiskClass {
let mut worst = RiskClass::ReadOnly;
let mut expect_head = true;
let mut after_wrapper = false;
for (i, tok) in tokens.iter().enumerate() {
let t = tok.as_str();
if t == "tee" || t == "dd" {
worst = shell_max(worst, RiskClass::ShellMutation);
} else if redirect_target_after(t).is_some() {
match redirect_write_target(tokens, i) {
Some(target) if is_safe_device_write(target) => {},
_ => worst = shell_max(worst, RiskClass::ShellMutation),
}
}
if !expect_head {
continue;
}
let head = basename(t);
if t == "command"
&& tokens[i + 1..]
.iter()
.take_while(|a| a.starts_with('-'))
.any(|a| a == "-v" || a == "-V")
{
expect_head = false;
continue;
}
if (t.contains('=') && !t.starts_with('-') && !t.contains('/')) || WRAPPERS.contains(&head)
{
after_wrapper = true;
continue;
}
if after_wrapper && t.starts_with('-') {
continue;
}
worst = shell_max(worst, classify_head(head, &tokens[i..]));
expect_head = false;
}
worst
}
pub(crate) fn is_dangerous_root(arg: &str) -> bool {
let a = arg.trim_matches(['"', '\'']);
let a = a.strip_suffix("/*").unwrap_or(a);
let a = a.strip_suffix("/.").unwrap_or(a);
let a = a.strip_suffix('/').unwrap_or(a);
let normalized = a.replace("${", "$").replace('}', "");
let collapsed = collapse_parent_refs(&normalized);
let a = collapsed.strip_suffix('/').unwrap_or(&collapsed);
if a.is_empty() {
return true;
}
if matches!(
a,
"~" | "$home"
| "."
| ".."
| "*"
| "/etc"
| "/usr"
| "/var"
| "/home"
| "/boot"
| "/lib"
| "/lib64"
| "/bin"
| "/sbin"
| "/sys"
| "/dev"
| "/root"
| "/opt"
) {
return true;
}
let aw = a.to_ascii_lowercase();
matches!(
aw.as_str(),
"c:" | "c:\\"
| "c:/"
| "\\"
| "%systemroot%"
| "%systemdrive%"
| "%userprofile%"
| "%homepath%"
) || aw.starts_with("c:\\windows")
|| aw.starts_with("c:/windows")
|| aw.starts_with("c:windows")
|| aw.starts_with("c:\\users")
|| aw.starts_with("c:/users")
|| aw.starts_with("c:users")
}
pub(crate) fn is_fork_bomb(nospace: &str) -> bool {
if nospace.contains(":(){") || nospace.contains(":|:&") {
return true;
}
let bytes = nospace.as_bytes();
let mut search = 0;
while let Some(rel) = nospace[search..].find("(){") {
let def_at = search + rel;
let mut start = def_at;
while start > 0 {
let c = bytes[start - 1];
if c.is_ascii_alphanumeric() || c == b'_' {
start -= 1;
} else {
break;
}
}
if start < def_at {
let name = &nospace[start..def_at];
if nospace.contains(&format!("{name}|{name}&")) {
return true;
}
}
search = def_at + 3;
}
false
}
pub(crate) fn segment_has_flag(segment: &[String], short: char, long: &str) -> bool {
segment.iter().skip(1).any(|t| {
if let Some(rest) = t.strip_prefix("--") {
rest == long || rest.split('=').next() == Some(long)
} else if let Some(bundle) = t.strip_prefix('-') {
!bundle.is_empty()
&& bundle.chars().all(|c| c.is_ascii_alphanumeric())
&& bundle.contains(short)
} else {
false
}
})
}
pub(crate) fn flag_present(tokens: &[String], want: char) -> bool {
tokens.iter().any(|t| {
if let Some(long) = t.strip_prefix("--") {
(want == 'r' && long == "recursive") || (want == 'f' && long == "force")
} else if let Some(short) = t.strip_prefix('-') {
!short.is_empty()
&& short.chars().all(|c| c.is_ascii_alphabetic())
&& short.contains(want)
} else {
false
}
})
}
pub(crate) const SHELL_INTERPRETERS: &[&str] = &["sh", "bash", "zsh", "dash", "ksh", "ash"];