omnyssh_core/ssh/
probe.rs1use std::collections::HashMap;
8
9pub fn generate_quick_scan_script() -> &'static str {
15 r#"cat << 'OMNYSSH_PROBE_EOF' | bash
16echo "===OMNYSSH:OS==="
17cat /etc/os-release 2>/dev/null | head -5
18echo "===OMNYSSH:SERVICES==="
19systemctl list-units --type=service --state=running --no-pager --no-legend 2>/dev/null | awk '{print $1}' | head -50
20echo "===OMNYSSH:DOCKER==="
21docker ps --format '{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}' 2>/dev/null | head -30
22echo "===OMNYSSH:LISTEN==="
23ss -tlnp 2>/dev/null | tail -n +2 | head -30
24echo "===OMNYSSH:PROCESS==="
25ps aux --sort=-%mem 2>/dev/null | head -15
26OMNYSSH_PROBE_EOF
27"#
28}
29
30#[derive(Debug, Clone, Default)]
32pub struct ProbeOutput {
33 sections: HashMap<String, String>,
34}
35
36impl ProbeOutput {
37 pub fn parse(output: &str) -> anyhow::Result<Self> {
45 let mut sections = HashMap::new();
46 let mut current_section: Option<String> = None;
47 let mut current_content = String::new();
48
49 for line in output.lines() {
50 let trimmed = line.trim();
51
52 if trimmed.starts_with("===OMNYSSH:") && trimmed.ends_with("===") {
54 if let Some(section_name) = current_section.take() {
56 sections.insert(section_name, current_content.trim().to_string());
57 current_content.clear();
58 }
59
60 let section_name = trimmed
62 .strip_prefix("===OMNYSSH:")
63 .and_then(|s| s.strip_suffix("==="))
64 .unwrap_or("")
65 .to_string();
66
67 current_section = Some(section_name);
68 } else if current_section.is_some() {
69 current_content.push_str(line);
71 current_content.push('\n');
72 }
73 }
74
75 if let Some(section_name) = current_section {
77 sections.insert(section_name, current_content.trim().to_string());
78 }
79
80 Ok(Self { sections })
81 }
82
83 pub fn has_section(&self, name: &str) -> bool {
85 self.sections
86 .get(name)
87 .map(|content| !content.is_empty())
88 .unwrap_or(false)
89 }
90
91 pub fn get_section(&self, name: &str) -> Option<&str> {
93 self.sections.get(name).map(|s| s.as_str())
94 }
95
96 pub fn parse_os_info(&self) -> Option<String> {
101 let os_section = self.get_section("OS")?;
102
103 let mut name: Option<String> = None;
104 let mut version: Option<String> = None;
105 let mut pretty_name: Option<String> = None;
106
107 for line in os_section.lines() {
108 let line = line.trim();
109
110 if line.starts_with("PRETTY_NAME=") {
112 pretty_name = extract_value(line, "PRETTY_NAME=");
113 } else if line.starts_with("NAME=") {
114 name = extract_value(line, "NAME=");
115 } else if line.starts_with("VERSION=") {
116 version = extract_value(line, "VERSION=");
117 }
118 }
119
120 if let Some(pretty) = pretty_name {
122 return Some(pretty);
123 }
124
125 match (name, version) {
127 (Some(n), Some(v)) => Some(format!("{} {}", n, v)),
128 (Some(n), None) => Some(n),
129 _ => None,
130 }
131 }
132}
133
134fn extract_value(line: &str, prefix: &str) -> Option<String> {
137 let value = line.strip_prefix(prefix)?.trim();
138
139 if (value.starts_with('"') && value.ends_with('"')
141 || value.starts_with('\'') && value.ends_with('\''))
142 && value.len() >= 2
143 {
144 Some(value[1..value.len() - 1].to_string())
145 } else {
146 Some(value.to_string())
147 }
148}
149
150#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_parse_probe_empty_output() {
160 let result = ProbeOutput::parse("").expect("should parse empty");
161 assert!(result.sections.is_empty());
162 }
163
164 #[test]
165 fn test_parse_probe_garbage_output() {
166 let result = ProbeOutput::parse("random\ngarbage\ntext").expect("should parse garbage");
167 assert!(result.sections.is_empty());
168 }
169
170 #[test]
171 fn test_parse_probe_single_section() {
172 let output = "===OMNYSSH:OS===\nUbuntu 22.04 LTS\n===OMNYSSH:SERVICES===\nsshd.service\n";
173 let result = ProbeOutput::parse(output).expect("should parse");
174 assert!(result.has_section("OS"));
175 assert!(result.has_section("SERVICES"));
176 assert_eq!(result.get_section("OS"), Some("Ubuntu 22.04 LTS"));
177 assert_eq!(result.get_section("SERVICES"), Some("sshd.service"));
178 }
179
180 #[test]
181 fn test_parse_probe_multiple_sections() {
182 let output = r#"===OMNYSSH:OS===
183NAME="Ubuntu"
184VERSION="22.04 LTS"
185===OMNYSSH:DOCKER===
186abc123 nginx-proxy Up 2 hours nginx:latest
187def456 db-master Up 5 days postgres:15
188===OMNYSSH:LISTEN===
1890.0.0.0:22 LISTEN
1900.0.0.0:80 LISTEN
191"#;
192 let result = ProbeOutput::parse(output).expect("should parse");
193 assert!(result.has_section("OS"));
194 assert!(result.has_section("DOCKER"));
195 assert!(result.has_section("LISTEN"));
196
197 let docker_section = result
198 .get_section("DOCKER")
199 .expect("docker section should exist");
200 assert!(docker_section.contains("nginx-proxy"));
201 assert!(docker_section.contains("postgres:15"));
202 }
203
204 #[test]
205 fn test_parse_probe_with_empty_sections() {
206 let output = "===OMNYSSH:OS===\nUbuntu\n===OMNYSSH:DOCKER===\n===OMNYSSH:SERVICES===\nsshd.service\n";
207 let result = ProbeOutput::parse(output).expect("should parse");
208 assert!(result.has_section("OS"));
209 assert!(!result.has_section("DOCKER")); assert!(result.has_section("SERVICES"));
211 }
212
213 #[test]
214 fn test_generate_quick_scan_script() {
215 let script = generate_quick_scan_script();
216 assert!(script.contains("===OMNYSSH:OS==="));
217 assert!(script.contains("===OMNYSSH:SERVICES==="));
218 assert!(script.contains("===OMNYSSH:DOCKER==="));
219 assert!(script.contains("===OMNYSSH:LISTEN==="));
220 assert!(script.contains("===OMNYSSH:PROCESS==="));
221 assert!(script.contains("/etc/os-release"));
222 assert!(script.contains("systemctl list-units"));
223 assert!(script.contains("docker ps"));
224 }
225}