Skip to main content

ploit_malper/ingest/
scanner.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::db::models::sha256_hex;
7
8/// Maximum size (bytes) a single artifact file may be. Anything larger is
9/// rejected as unlikely to be a Malper scan artifact.
10pub const MAX_ARTIFACT_SIZE: u64 = 16 * 1024 * 1024;
11
12/// The recognised Malper artifact kinds.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ArtifactKind {
15    NetMalperGraph,
16    VulnMalperJson,
17    VulnMalperMarkdown,
18    PloitMalperReport,
19    /// A `Malper analyse` report: recognised but intentionally ignored.
20    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/// An accepted scan artifact.
36#[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/// A file that was scanned and rejected, with the reason.
46#[derive(Debug, Clone)]
47pub struct RejectedFile {
48    pub path: PathBuf,
49    pub reason: String,
50}
51
52/// The outcome of scanning a folder.
53#[derive(Debug, Default)]
54pub struct ScanResult {
55    pub artifacts: Vec<Artifact>,
56    pub rejected: Vec<RejectedFile>,
57}
58
59// ---------------------------------------------------------------------------
60// Detection
61// ---------------------------------------------------------------------------
62
63/// Detect the artifact kind from content only. `None` means the file is not a
64/// recognised Malper artifact and should be rejected.
65pub fn detect_kind(content: &str) -> Option<ArtifactKind> {
66    if content.trim().is_empty() {
67        return None;
68    }
69
70    // Try JSON first.
71    if let Ok(value) = serde_json::from_str::<Value>(content) {
72        return detect_json(&value);
73    }
74
75    // Non-JSON: markdown report detection from content markers.
76    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    // NetMalper graph: meta + nodes + edges.
93    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    // VulnMalper export: vulnmalper metadata + findings or hosts.
107    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    // Unknown JSON with a run_id marker but no structural match.
119    None
120}
121
122// ---------------------------------------------------------------------------
123// Scanning
124// ---------------------------------------------------------------------------
125
126/// Recursively scan `root` and classify every file. Never relies on file
127/// names alone — detection is content based.
128pub 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        // A file named like an artifact but with unrelated content is rejected.
314        let content = r#"{"anything": "else"}"#;
315        assert_eq!(detect_kind(content), None);
316    }
317}