1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::db::models::sha256_hex;
7
8pub const MAX_ARTIFACT_SIZE: u64 = 16 * 1024 * 1024;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ArtifactKind {
15 NetMalperGraph,
16 VulnMalperJson,
17 VulnMalperMarkdown,
18 PloitMalperReport,
19 MalperAnalyse,
21}
22
23impl ArtifactKind {
24 pub fn label(&self) -> &'static str {
25 match self {
26 ArtifactKind::NetMalperGraph => "netmalper graph",
27 ArtifactKind::VulnMalperJson => "vulnmalper json",
28 ArtifactKind::VulnMalperMarkdown => "vulnmalper markdown",
29 ArtifactKind::PloitMalperReport => "ploitmalper report",
30 ArtifactKind::MalperAnalyse => "malper analyse report",
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct Artifact {
38 pub kind: ArtifactKind,
39 pub path: PathBuf,
40 pub content: String,
41 pub size: u64,
42 pub hash: String,
43}
44
45#[derive(Debug, Clone)]
47pub struct RejectedFile {
48 pub path: PathBuf,
49 pub reason: String,
50}
51
52#[derive(Debug, Default)]
54pub struct ScanResult {
55 pub artifacts: Vec<Artifact>,
56 pub rejected: Vec<RejectedFile>,
57}
58
59pub fn detect_kind(content: &str) -> Option<ArtifactKind> {
66 if content.trim().is_empty() {
67 return None;
68 }
69
70 if let Ok(value) = serde_json::from_str::<Value>(content) {
72 return detect_json(&value);
73 }
74
75 if content.contains("# PloitMalper - Vulnerability Analysis Report") {
77 return Some(ArtifactKind::PloitMalperReport);
78 }
79 if content.contains("VulnMalper Report") {
80 return Some(ArtifactKind::VulnMalperMarkdown);
81 }
82 if content.contains("Malper Analyse Report") || content.contains("Malper Analysis Report") {
83 return Some(ArtifactKind::MalperAnalyse);
84 }
85
86 None
87}
88
89fn detect_json(value: &Value) -> Option<ArtifactKind> {
90 let object = value.as_object()?;
91
92 let has_meta = object
94 .get("meta")
95 .and_then(Value::as_object)
96 .and_then(|m| m.get("target"))
97 .and_then(Value::as_str)
98 .map(|t| !t.is_empty())
99 .unwrap_or(false);
100 let has_nodes = object.get("nodes").and_then(Value::as_array).is_some();
101 let has_edges = object.get("edges").and_then(Value::as_array).is_some();
102 if has_meta && has_nodes && has_edges {
103 return Some(ArtifactKind::NetMalperGraph);
104 }
105
106 let has_vulnmeta = object
108 .get("vulnmalper")
109 .and_then(Value::as_object)
110 .map(|m| m.contains_key("version") || m.contains_key("source_target"))
111 .unwrap_or(false);
112 let has_findings = object.get("findings").and_then(Value::as_array).is_some();
113 let has_hosts = object.get("hosts").and_then(Value::as_array).is_some();
114 if has_vulnmeta && (has_findings || has_hosts) {
115 return Some(ArtifactKind::VulnMalperJson);
116 }
117
118 None
120}
121
122pub fn scan_folder(root: &Path, verbose: bool) -> crate::error::Result<ScanResult> {
129 if !root.exists() {
130 return Err(crate::error::AppError::Message(format!(
131 "scan folder does not exist: {}",
132 root.display()
133 )));
134 }
135 if !root.is_dir() {
136 return Err(crate::error::AppError::Message(format!(
137 "ingest path is not a directory: {}",
138 root.display()
139 )));
140 }
141
142 let mut result = ScanResult::default();
143 walk(root, &mut result, verbose)?;
144
145 if verbose {
146 println!(
147 "[dbg] scan complete: {} artifact(s), {} rejected file(s)",
148 result.artifacts.len(),
149 result.rejected.len()
150 );
151 }
152 Ok(result)
153}
154
155fn walk(dir: &Path, result: &mut ScanResult, verbose: bool) -> crate::error::Result<()> {
156 for entry in fs::read_dir(dir)? {
157 let entry = entry?;
158 let path = entry.path();
159 let file_type = entry.file_type()?;
160
161 if file_type.is_dir() {
162 walk(&path, result, verbose)?;
163 continue;
164 }
165 if !file_type.is_file() {
166 continue;
167 }
168
169 process_file(&path, result, verbose);
170 }
171 Ok(())
172}
173
174fn process_file(path: &Path, result: &mut ScanResult, verbose: bool) {
175 let metadata = match fs::metadata(path) {
176 Ok(m) => m,
177 Err(e) => {
178 result.rejected.push(RejectedFile {
179 path: path.to_path_buf(),
180 reason: format!("unreadable metadata: {e}"),
181 });
182 return;
183 }
184 };
185
186 if metadata.len() == 0 {
187 result.rejected.push(RejectedFile {
188 path: path.to_path_buf(),
189 reason: "empty file".to_string(),
190 });
191 return;
192 }
193 if metadata.len() > MAX_ARTIFACT_SIZE {
194 result.rejected.push(RejectedFile {
195 path: path.to_path_buf(),
196 reason: format!(
197 "file too large ({} bytes) to be a Malper artifact",
198 metadata.len()
199 ),
200 });
201 return;
202 }
203
204 let bytes = match fs::read(path) {
205 Ok(b) => b,
206 Err(e) => {
207 result.rejected.push(RejectedFile {
208 path: path.to_path_buf(),
209 reason: format!("unreadable: {e}"),
210 });
211 return;
212 }
213 };
214
215 let content = match std::str::from_utf8(&bytes) {
216 Ok(s) => s.to_string(),
217 Err(_) => {
218 result.rejected.push(RejectedFile {
219 path: path.to_path_buf(),
220 reason: "binary or non-UTF-8 file".to_string(),
221 });
222 return;
223 }
224 };
225
226 match detect_kind(&content) {
227 Some(kind) => {
228 let hash = sha256_hex(&content);
229 if verbose {
230 println!(
231 "[dbg] detected {} -> {} ({} bytes)",
232 path.display(),
233 kind.label(),
234 metadata.len()
235 );
236 }
237 result.artifacts.push(Artifact {
238 kind,
239 path: path.to_path_buf(),
240 content,
241 size: metadata.len(),
242 hash,
243 });
244 }
245 None => {
246 let reason = if content.starts_with('{') || content.starts_with('[') {
247 "JSON content does not match any known Malper artifact"
248 } else {
249 "text file is not a recognized Malper artifact"
250 };
251 if verbose {
252 println!("[dbg] rejected {} -> {}", path.display(), reason);
253 }
254 result.rejected.push(RejectedFile {
255 path: path.to_path_buf(),
256 reason: reason.to_string(),
257 });
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 const NETMALPER: &str = r#"{
267 "meta": {"target": "192.168.1.14", "timestamp": "2026-08-01T07:39:22Z", "version": "8.0.0"},
268 "nodes": [{"id": "ip:192.168.1.14", "type": "ip", "data": {"ip": "192.168.1.14"}}],
269 "edges": [{"source": "ip:192.168.1.14", "target": "ip:192.168.1.14", "label": "x"}]
270 }"#;
271
272 const VULN_JSON: &str = r#"{
273 "vulnmalper": {"version": "8.0.0", "source_target": "192.168.1.14"},
274 "findings": [{"tool": "nikto", "severity": "low", "title": "x"}]
275 }"#;
276
277 #[test]
278 fn detects_netmalper_json() {
279 assert_eq!(detect_kind(NETMALPER), Some(ArtifactKind::NetMalperGraph));
280 }
281
282 #[test]
283 fn detects_vulnmalper_json() {
284 assert_eq!(detect_kind(VULN_JSON), Some(ArtifactKind::VulnMalperJson));
285 }
286
287 #[test]
288 fn rejects_unrelated_json() {
289 assert_eq!(detect_kind(r#"{"hello": "world"}"#), None);
290 assert_eq!(detect_kind("[1,2,3]"), None);
291 }
292
293 #[test]
294 fn detects_markdown_reports() {
295 assert_eq!(
296 detect_kind("# 🛡️ VulnMalper Report\n\n> **Target:** `x`"),
297 Some(ArtifactKind::VulnMalperMarkdown)
298 );
299 assert_eq!(
300 detect_kind("# PloitMalper - Vulnerability Analysis Report\n\n## Executive Summary"),
301 Some(ArtifactKind::PloitMalperReport)
302 );
303 }
304
305 #[test]
306 fn rejects_random_markdown_and_binary() {
307 assert_eq!(detect_kind("# Random Notes\n\nhello world"), None);
308 assert_eq!(detect_kind("plain text log line"), None);
309 }
310
311 #[test]
312 fn name_is_never_used_for_detection() {
313 let content = r#"{"anything": "else"}"#;
315 assert_eq!(detect_kind(content), None);
316 }
317}