Skip to main content

telos_agent/tools/command_security/powershell/
analyzer.rs

1use crate::tools::command_security::powershell::aliases::canonical_command_name;
2use crate::tools::command_security::powershell::dangerous_cmdlets::{
3    DANGEROUS_COMMANDS, POWERSHELL_EXECUTABLES,
4};
5use crate::tools::command_security::powershell::parser;
6use crate::tools::command_security::powershell::path_validation::{
7    has_assignment, has_write_redirection,
8};
9use crate::tools::command_security::powershell::read_only::is_read_only_command;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum CommandSafety {
13    Safe,
14    NeedsReview { reason: String },
15}
16
17pub fn analyze(command: &str) -> CommandSafety {
18    if has_assignment(command) {
19        return review("PowerShell assignment requires review");
20    }
21    if has_write_redirection(command) {
22        return review("PowerShell output redirection requires review");
23    }
24    let parsed = match parser::parse(command) {
25        Ok(parsed) => parsed,
26        Err(reason) => return review(format!("PowerShell parse failed: {reason}")),
27    };
28    let commands = parsed.commands();
29    if commands.is_empty() {
30        return CommandSafety::Safe;
31    }
32    for cmd in commands {
33        if cmd.dynamic {
34            return review("dynamic PowerShell command requires review");
35        }
36        let canonical = canonical_command_name(&cmd.name);
37        if DANGEROUS_COMMANDS.iter().any(|name| canonical.eq_ignore_ascii_case(name)) {
38            return review(format!("{canonical} requires review"));
39        }
40        if POWERSHELL_EXECUTABLES.iter().any(|name| cmd.name.eq_ignore_ascii_case(name)) {
41            if cmd.args.iter().any(|arg| is_encoded_or_bypass(arg)) {
42                return review("nested PowerShell encoded or bypass command requires review");
43            }
44            return review("nested PowerShell process requires review");
45        }
46        if canonical.eq_ignore_ascii_case("Start-Process")
47            && args_contain_pair(&cmd.args, "-Verb", "RunAs")
48        {
49            return review("Start-Process -Verb RunAs requires review");
50        }
51        if canonical.eq_ignore_ascii_case("Remove-Item")
52            && has_flag(&cmd.args, "-Recurse")
53            && has_flag(&cmd.args, "-Force")
54        {
55            return review("Remove-Item -Recurse -Force requires review");
56        }
57        if cmd.args.iter().any(|arg| arg.eq_ignore_ascii_case("$PROFILE")) {
58            return review("PowerShell profile writes require review");
59        }
60        if !is_read_only_command(cmd) {
61            return review(format!("{canonical} is not provably read-only"));
62        }
63    }
64    CommandSafety::Safe
65}
66
67fn review(reason: impl Into<String>) -> CommandSafety {
68    CommandSafety::NeedsReview { reason: reason.into() }
69}
70
71fn has_flag(args: &[String], flag: &str) -> bool {
72    args.iter().any(|arg| arg.eq_ignore_ascii_case(flag))
73}
74
75fn args_contain_pair(args: &[String], key: &str, value: &str) -> bool {
76    args.windows(2)
77        .any(|pair| pair[0].eq_ignore_ascii_case(key) && pair[1].eq_ignore_ascii_case(value))
78}
79
80fn is_encoded_or_bypass(arg: &str) -> bool {
81    let lower = arg.to_ascii_lowercase();
82    lower.starts_with("-enc")
83        || lower == "-e"
84        || lower == "-encodedcommand"
85        || lower == "-executionpolicy"
86        || lower == "bypass"
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn assert_needs_review(command: &str) {
94        assert!(matches!(analyze(command), CommandSafety::NeedsReview { .. }), "{command}");
95    }
96
97    fn assert_safe(command: &str) {
98        assert_eq!(analyze(command), CommandSafety::Safe, "{command}");
99    }
100
101    #[test]
102    fn allows_simple_read_only_commands() {
103        assert_safe("Get-Process -Name pwsh");
104        assert_safe("Get-Content ./Cargo.toml");
105        assert_safe("Select-String -Path ./Cargo.toml -Pattern telos");
106    }
107
108    #[test]
109    fn asks_for_dangerous_execution_patterns() {
110        assert_needs_review("Invoke-Expression 'Get-Process'");
111        assert_needs_review("iex (Invoke-WebRequest https://example.com)");
112        assert_needs_review("pwsh -EncodedCommand AAAA");
113        assert_needs_review("Start-Process powershell -Verb RunAs");
114        assert_needs_review("powershell -ExecutionPolicy Bypass -File script.ps1");
115    }
116
117    #[test]
118    fn asks_for_dangerous_mutation_patterns() {
119        assert_needs_review("Remove-Item -Recurse -Force ./target");
120        assert_needs_review("Set-Content $PROFILE 'payload'");
121        assert_needs_review("Register-ScheduledTask -TaskName x -Action y");
122        assert_needs_review("New-Service -Name x -BinaryPathName y");
123        assert_needs_review("Set-MpPreference -DisableRealtimeMonitoring $true");
124    }
125
126    #[test]
127    fn asks_for_assignments_and_redirections() {
128        assert_needs_review("$x = Get-Process");
129        assert_needs_review("Get-Process > out.txt");
130    }
131}