use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
use base64::Engine;
use std::collections::HashSet;
use std::path::Path;
const DETACH_COMMANDS: &[(&str, &str)] = &[
("nohup", "nohup"),
("setsid", "setsid"),
("disown", "disown"),
("systemd-run", "systemd-run"),
("launchctl", "launchctl"),
("crontab", "crontab"),
("at", "at"),
("screen", "screen"),
("tmux", "tmux"),
("daemonize", "daemonize"),
];
const MAX_RECURSION_DEPTH: usize = 3;
const MIN_BASE64_LEN: usize = 16;
const MAX_BASE64_LEN: usize = 8192;
const SHELL_C_WRAPPERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh", "csh", "tcsh"];
const INLINE_CODE_WRAPPERS: &[&str] = &["python", "python3", "node", "perl", "ruby", "php"];
pub fn detect_daemonization_primitives(input: &str) -> Vec<&'static str> {
let mut hits: Vec<&'static str> = Vec::new();
let mut visited = HashSet::new();
detect_recursive(input, 0, &mut hits, &mut visited);
hits
}
fn detect_recursive(
input: &str,
depth: usize,
hits: &mut Vec<&'static str>,
visited: &mut HashSet<String>,
) {
if depth > MAX_RECURSION_DEPTH {
return;
}
let normalized = input.trim().to_ascii_lowercase();
if normalized.is_empty() || !visited.insert(normalized) {
return;
}
detect_direct_primitives(input, hits);
if depth == MAX_RECURSION_DEPTH {
return;
}
for nested in extract_nested_payloads(input) {
detect_recursive(&nested, depth + 1, hits, visited);
}
for decoded in extract_decoded_payloads(input) {
detect_recursive(&decoded, depth + 1, hits, visited);
}
}
fn detect_direct_primitives(input: &str, hits: &mut Vec<&'static str>) {
let lower = input.to_ascii_lowercase();
if contains_unquoted_background_operator(input) {
push_unique(hits, "background operator (&)");
}
for (segment, _) in crate::tools::command_risk::split_by_operators(input) {
if segment.is_empty() {
continue;
}
let parts = match shell_words::split(&segment) {
Ok(p) => p,
Err(_) => continue,
};
if parts.is_empty() {
continue;
}
let base = Path::new(&parts[0])
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&parts[0])
.to_ascii_lowercase();
for (token, label) in DETACH_COMMANDS {
if base == *token {
push_unique(hits, label);
}
}
}
for (token, label) in DETACH_COMMANDS {
if *token == "at" {
continue;
}
if contains_word_ascii_ci(&lower, token) || contains_obfuscated_word_ascii_ci(input, token)
{
push_unique(hits, label);
}
}
}
fn push_unique(out: &mut Vec<&'static str>, value: &'static str) {
if !out.contains(&value) {
out.push(value);
}
}
fn contains_unquoted_background_operator(input: &str) -> bool {
let chars: Vec<char> = input.chars().collect();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escaped = false;
for (i, ch) in chars.iter().enumerate() {
let ch = *ch;
if escaped {
escaped = false;
continue;
}
if ch == '\\' && !in_single_quote {
escaped = true;
continue;
}
if ch == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
continue;
}
if ch == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
continue;
}
if in_single_quote || in_double_quote || ch != '&' {
continue;
}
let prev = chars.get(i.saturating_sub(1)).copied();
let next = chars.get(i + 1).copied();
if prev == Some('&') || next == Some('&') {
continue; }
let prev_non_ws = chars[..i]
.iter()
.rev()
.find(|c| !c.is_whitespace())
.copied();
let next_non_ws = chars[i + 1..].iter().find(|c| !c.is_whitespace()).copied();
if matches!(prev_non_ws, Some('>') | Some('<'))
|| matches!(next_non_ws, Some('>') | Some('<'))
{
continue; }
return true;
}
false
}
fn contains_word_ascii_ci(lower_haystack: &str, lower_word: &str) -> bool {
if lower_word.is_empty() {
return false;
}
let bytes = lower_haystack.as_bytes();
let needle = lower_word.as_bytes();
if needle.len() > bytes.len() {
return false;
}
for i in 0..=bytes.len() - needle.len() {
if &bytes[i..i + needle.len()] != needle {
continue;
}
let left_ok = i == 0 || !is_word_char(bytes[i - 1] as char);
let right_ok =
i + needle.len() == bytes.len() || !is_word_char(bytes[i + needle.len()] as char);
if left_ok && right_ok {
return true;
}
}
false
}
fn is_word_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
}
fn contains_obfuscated_word_ascii_ci(input: &str, token: &str) -> bool {
if token.is_empty() {
return false;
}
let haystack: Vec<char> = input.to_ascii_lowercase().chars().collect();
let needle: Vec<char> = token.chars().collect();
if needle.len() > haystack.len() {
return false;
}
for start in 0..haystack.len() {
if haystack[start] != needle[0] {
continue;
}
let left_sig = haystack[..start]
.iter()
.rev()
.find(|c| !is_obfuscation_separator(**c))
.copied();
if matches!(left_sig, Some(ch) if is_word_char(ch)) {
continue;
}
let mut i = start + 1;
let mut j = 1;
while j < needle.len() && i < haystack.len() {
let ch = haystack[i];
if ch == needle[j] {
j += 1;
i += 1;
continue;
}
if is_obfuscation_separator(ch) {
i += 1;
continue;
}
break;
}
if j == needle.len() {
let right_sig = haystack[i..]
.iter()
.find(|c| !is_obfuscation_separator(**c))
.copied();
if !matches!(right_sig, Some(ch) if is_word_char(ch)) {
return true;
}
}
}
false
}
fn is_obfuscation_separator(ch: char) -> bool {
matches!(
ch,
'\\' | '\'' | '"' | '`' | '$' | '{' | '}' | '(' | ')' | '[' | ']' | '+' | '.'
)
}
fn extract_nested_payloads(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for (segment, _) in crate::tools::command_risk::split_by_operators(input) {
if segment.is_empty() {
continue;
}
let parts = match shell_words::split(&segment) {
Ok(parts) => parts,
Err(_) => continue,
};
if parts.is_empty() {
continue;
}
let Some((base, cmd_index)) = primary_command(&parts) else {
continue;
};
if base == "eval" {
if cmd_index + 1 < parts.len() {
let payload = parts[cmd_index + 1..].join(" ");
push_unique_owned(&mut out, &mut seen, payload);
}
continue;
}
if SHELL_C_WRAPPERS.contains(&base.as_str()) {
if let Some(payload) = extract_flag_payload(&parts, cmd_index + 1, PayloadMode::ShellC)
{
push_unique_owned(&mut out, &mut seen, payload);
}
continue;
}
if INLINE_CODE_WRAPPERS.contains(&base.as_str()) {
if let Some(payload) =
extract_flag_payload(&parts, cmd_index + 1, PayloadMode::InlineCode)
{
push_unique_owned(&mut out, &mut seen, payload);
}
}
}
out
}
#[derive(Clone, Copy)]
enum PayloadMode {
ShellC,
InlineCode,
}
fn extract_flag_payload(parts: &[String], start: usize, mode: PayloadMode) -> Option<String> {
let mut i = start;
while i < parts.len() {
let token = parts[i].as_str();
match mode {
PayloadMode::ShellC => {
if token == "-c" {
return parts.get(i + 1).cloned();
}
if token.starts_with("-c") && token.len() > 2 {
return Some(token[2..].to_string());
}
if has_short_flag(token, 'c') {
return parts.get(i + 1).cloned();
}
}
PayloadMode::InlineCode => {
if token == "-e" || token == "-c" || token == "--eval" {
return parts.get(i + 1).cloned();
}
if (token.starts_with("-e") || token.starts_with("-c")) && token.len() > 2 {
return Some(token[2..].to_string());
}
if token == "-pe" || token == "-ep" {
return parts.get(i + 1).cloned();
}
}
}
i += 1;
}
None
}
fn has_short_flag(token: &str, flag: char) -> bool {
if !token.starts_with('-') || token.starts_with("--") || token.len() <= 2 {
return false;
}
token.chars().skip(1).any(|c| c == flag)
}
fn primary_command(parts: &[String]) -> Option<(String, usize)> {
let mut i = 0;
while i < parts.len() {
let base = command_basename(&parts[i]);
match base.as_str() {
"env" => {
i += 1;
while i < parts.len() {
let token = parts[i].as_str();
if token == "--" {
i += 1;
break;
}
if token.starts_with('-') || is_env_assignment(token) {
i += 1;
continue;
}
break;
}
}
"sudo" | "doas" => {
i += 1;
while i < parts.len() {
let option = parts[i].as_str();
if option == "--" {
i += 1;
break;
}
if !option.starts_with('-') {
break;
}
i += 1;
if option_takes_value(base.as_str(), option) && i < parts.len() {
i += 1;
}
}
}
"command" | "builtin" | "time" => {
i += 1;
while i < parts.len() && parts[i].starts_with('-') {
i += 1;
}
}
"nice" => {
i += 1;
while i < parts.len() && parts[i].starts_with('-') {
let option = parts[i].as_str();
i += 1;
if (option == "-n" || option == "--adjustment") && i < parts.len() {
i += 1;
}
}
}
_ => return Some((base, i)),
}
}
None
}
fn command_basename(token: &str) -> String {
Path::new(token)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(token)
.to_ascii_lowercase()
}
fn is_env_assignment(token: &str) -> bool {
!token.starts_with('-') && token.contains('=') && !token.starts_with('=')
}
fn option_takes_value(wrapper: &str, option: &str) -> bool {
match wrapper {
"sudo" => matches!(
option,
"-u" | "-g" | "-h" | "-C" | "-p" | "-r" | "-t" | "-T"
),
"doas" => option == "-u",
_ => false,
}
}
fn extract_decoded_payloads(input: &str) -> Vec<String> {
let lower = input.to_ascii_lowercase();
if !has_decode_hint(&lower) {
return Vec::new();
}
let mut out = Vec::new();
let mut seen = HashSet::new();
for candidate in extract_base64_candidates(input) {
if let Some(decoded) = decode_base64_candidate(&candidate) {
push_unique_owned(&mut out, &mut seen, decoded);
}
}
out
}
fn has_decode_hint(lower_input: &str) -> bool {
lower_input.contains("base64 -d")
|| lower_input.contains("base64 --decode")
|| lower_input.contains("base64 -decode")
|| lower_input.contains("b64decode")
|| lower_input.contains("base64decode")
|| lower_input.contains("frombase64string")
|| lower_input.contains("decode64")
|| lower_input.contains("openssl enc -d -base64")
}
fn extract_base64_candidates(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for (segment, _) in crate::tools::command_risk::split_by_operators(input) {
if segment.is_empty() {
continue;
}
let parts = match shell_words::split(&segment) {
Ok(parts) => parts,
Err(_) => continue,
};
for part in parts {
maybe_push_base64_candidate(&part, &mut out, &mut seen);
}
}
let mut run = String::new();
for ch in input.chars() {
if is_base64_char(ch) {
run.push(ch);
} else if !run.is_empty() {
maybe_push_base64_candidate(&run, &mut out, &mut seen);
run.clear();
}
}
if !run.is_empty() {
maybe_push_base64_candidate(&run, &mut out, &mut seen);
}
out
}
fn maybe_push_base64_candidate(token: &str, out: &mut Vec<String>, seen: &mut HashSet<String>) {
let cleaned = token.trim_matches(|c: char| {
matches!(
c,
'"' | '\'' | '`' | '$' | '(' | ')' | '{' | '}' | '[' | ']' | ';' | ','
)
});
if !is_plausible_base64(cleaned) {
return;
}
if seen.insert(cleaned.to_string()) {
out.push(cleaned.to_string());
}
}
fn is_base64_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '+' | '/' | '=' | '-' | '_')
}
fn is_plausible_base64(s: &str) -> bool {
if s.len() < MIN_BASE64_LEN || s.len() > MAX_BASE64_LEN {
return false;
}
if s.starts_with('-') || s.len() % 4 == 1 {
return false;
}
if !s.chars().all(is_base64_char) {
return false;
}
let mut has_lower = false;
let mut has_upper = false;
let mut has_digit = false;
let mut has_symbol = false;
for ch in s.chars() {
if ch.is_ascii_lowercase() {
has_lower = true;
} else if ch.is_ascii_uppercase() {
has_upper = true;
} else if ch.is_ascii_digit() {
has_digit = true;
} else {
has_symbol = true;
}
}
if !(has_digit && (has_lower || has_upper)) {
return false;
}
if s.len() < 24 && !(has_lower && has_upper && has_digit) {
return false;
}
has_symbol || s.len() >= 20
}
fn decode_base64_candidate(candidate: &str) -> Option<String> {
if !is_plausible_base64(candidate) {
return None;
}
let mut attempts = vec![candidate.to_string()];
if !candidate.len().is_multiple_of(4) {
let pad_len = (4 - (candidate.len() % 4)) % 4;
if pad_len > 0 {
attempts.push(format!("{}{}", candidate, "=".repeat(pad_len)));
}
}
for encoded in attempts {
for decoded in [
STANDARD.decode(encoded.as_bytes()),
STANDARD_NO_PAD.decode(encoded.as_bytes()),
URL_SAFE.decode(encoded.as_bytes()),
URL_SAFE_NO_PAD.decode(encoded.as_bytes()),
] {
let Ok(bytes) = decoded else {
continue;
};
if bytes.is_empty() || bytes.len() > MAX_BASE64_LEN {
continue;
}
let Ok(text) = String::from_utf8(bytes) else {
continue;
};
if looks_like_text_payload(&text) {
return Some(text);
}
}
}
None
}
fn looks_like_text_payload(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let total = text.chars().count();
if total == 0 {
return false;
}
let printable = text
.chars()
.filter(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
.count();
printable * 100 / total >= 85
}
fn push_unique_owned(out: &mut Vec<String>, seen: &mut HashSet<String>, value: String) {
let trimmed = value.trim();
if trimmed.is_empty() {
return;
}
if seen.insert(trimmed.to_string()) {
out.push(trimmed.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_background_operator() {
let hits = detect_daemonization_primitives("python app.py &");
assert!(hits.contains(&"background operator (&)"));
}
#[test]
fn ignores_logical_and_and_redirection_ampersands() {
let hits = detect_daemonization_primitives("echo ok && echo done 2>&1");
assert!(!hits.contains(&"background operator (&)"));
}
#[test]
fn detects_detach_commands() {
let hits = detect_daemonization_primitives("nohup ./server; systemd-run --user myjob");
assert!(hits.contains(&"nohup"));
assert!(hits.contains(&"systemd-run"));
}
#[test]
fn avoids_at_false_positive_in_plain_text() {
let hits = detect_daemonization_primitives("look at this output please");
assert!(!hits.contains(&"at"));
}
#[test]
fn detects_eval_wrapped_obfuscated_detach() {
let hits = detect_daemonization_primitives(r#"eval "no\hup sleep 5 &""#);
assert!(hits.contains(&"nohup"));
assert!(hits.contains(&"background operator (&)"));
}
#[test]
fn detects_base64_decoded_detach_payload() {
let hits =
detect_daemonization_primitives(r#"eval "$(echo bm9odXAgc2xlZXAgNSAm | base64 -d)""#);
assert!(hits.contains(&"nohup"));
assert!(hits.contains(&"background operator (&)"));
}
#[test]
fn detects_python_b64decode_detach_payload() {
let hits = detect_daemonization_primitives(
r#"python3 -c "import base64;exec(base64.b64decode('bm9odXAgc2xlZXAgNSAm').decode())""#,
);
assert!(hits.contains(&"nohup"));
assert!(hits.contains(&"background operator (&)"));
}
#[test]
fn does_not_flag_eval_without_detach() {
let hits = detect_daemonization_primitives(r#"eval "echo safe""#);
assert!(!hits.contains(&"nohup"));
assert!(!hits.contains(&"background operator (&)"));
}
}