Skip to main content

ploit_malper/db/
models.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5use time::format_description::well_known::Rfc3339;
6use time::OffsetDateTime;
7
8pub fn now_utc() -> String {
9    OffsetDateTime::now_utc()
10        .format(&Rfc3339)
11        .unwrap_or_else(|_| format!("{:?}", std::time::SystemTime::now()))
12}
13
14pub fn sha256_hex(input: &str) -> String {
15    let mut hasher = Sha256::new();
16    hasher.update(input.as_bytes());
17    format!("{:x}", hasher.finalize())
18}
19
20fn stable_id(prefix: &str, key: &str) -> String {
21    sha256_hex(&format!("{}:{}", prefix, key))
22}
23
24pub const ASSET_ACTIVE: &str = "active";
25pub const ASSET_REMOVED: &str = "removed";
26
27/// A scannable target (IP, hostname, or root domain) with lifecycle state.
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct Asset {
30    pub stable_id: String,
31    pub asset_type: String,
32    pub name: String,
33    pub ip: Option<String>,
34    pub fqdn: Option<String>,
35    pub reverse_dns: Option<String>,
36    pub first_seen: String,
37    pub last_seen: String,
38    pub status: String,
39    #[serde(default)]
40    pub metadata: Value,
41}
42
43impl Asset {
44    pub fn new(name: &str, asset_type: &str) -> Self {
45        let mut asset = Self {
46            stable_id: stable_id("asset", name),
47            asset_type: asset_type.to_string(),
48            name: name.to_string(),
49            ..Default::default()
50        };
51        asset.metadata = Value::Object(Default::default());
52        let now = now_utc();
53        asset.first_seen = now.clone();
54        asset.last_seen = now;
55        asset.status = ASSET_ACTIVE.to_string();
56        asset
57    }
58}
59
60/// A network service discovered on an asset (open port + protocol + product).
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct Service {
63    pub stable_id: String,
64    pub asset_id: String,
65    pub port: u16,
66    pub protocol: String,
67    pub service_name: String,
68    pub product: Option<String>,
69    pub version: Option<String>,
70    pub version_str: Option<String>,
71    pub banner: Option<String>,
72    #[serde(default)]
73    pub technologies: Vec<String>,
74    #[serde(default)]
75    pub cpes: Vec<String>,
76    pub first_seen: String,
77    pub last_seen: String,
78    pub status: String,
79    #[serde(default)]
80    pub metadata: Value,
81}
82
83impl Service {
84    pub fn new(asset_id: &str, port: u16, protocol: &str, service_name: &str) -> Self {
85        let key = format!("{}:{}:{}:{}", asset_id, port, protocol, service_name);
86        let mut service = Self {
87            stable_id: stable_id("service", &key),
88            asset_id: asset_id.to_string(),
89            port,
90            protocol: protocol.to_string(),
91            service_name: service_name.to_string(),
92            ..Default::default()
93        };
94        service.metadata = Value::Object(Default::default());
95        let now = now_utc();
96        service.first_seen = now.clone();
97        service.last_seen = now;
98        service.status = ASSET_ACTIVE.to_string();
99        service
100    }
101}
102
103/// A vulnerability / security finding on an asset with exploitability tracking.
104#[derive(Debug, Clone, Serialize, Deserialize, Default)]
105pub struct Finding {
106    pub stable_id: String,
107    pub asset_id: String,
108    pub service_id: Option<String>,
109    pub title: String,
110    pub severity: String,
111    pub tool: String,
112    pub target_url: Option<String>,
113    /// Short excerpt kept in the database; full evidence lives in a file
114    /// referenced by `detail_path`.
115    pub detail: Option<String>,
116    /// Relative path into the content store holding the full detail/evidence.
117    pub detail_path: Option<String>,
118    pub reference: Option<String>,
119    #[serde(default)]
120    pub cves: Vec<String>,
121    #[serde(default)]
122    pub endpoints: Vec<String>,
123    #[serde(default)]
124    pub technologies: Vec<String>,
125    pub first_seen: String,
126    pub last_seen: String,
127    pub status: String,
128    pub exploitability: Option<String>,
129    #[serde(default)]
130    pub metadata: Value,
131}
132
133impl Finding {
134    /// Build a finding with a stable identity.
135    ///
136    /// The identity key deliberately folds only scanner *framing* differences
137    /// (surrounding whitespace, repeated/pipe-separated delimiters) so that the
138    /// same logical finding never forks into two database rows across scans.
139    /// Case and spelling are left untouched so previously imported rows keep
140    /// their existing stable_id (backward compatibility).
141    pub fn new(asset_id: &str, tool: &str, title: &str) -> Self {
142        let key = format!("{}:{}:{}", asset_id, tool, stable_title_identity(title));
143        let mut finding = Self {
144            stable_id: stable_id("finding", &key),
145            asset_id: asset_id.to_string(),
146            tool: tool.to_string(),
147            title: title.to_string(),
148            ..Default::default()
149        };
150        finding.metadata = Value::Object(Default::default());
151        let now = now_utc();
152        finding.first_seen = now.clone();
153        finding.last_seen = now;
154        finding.status = ASSET_ACTIVE.to_string();
155        finding
156    }
157}
158
159/// Collapse scanner framing around a finding title for identity purposes:
160/// surrounding whitespace, consecutive delimiters (`|`, `/`, `:`), and any
161/// stray pipe-separated empty fields. Content and case are preserved.
162fn stable_title_identity(title: &str) -> String {
163    let collapsed = title.split_whitespace().collect::<Vec<_>>().join(" ");
164    let parts: Vec<&str> = collapsed
165        .split('|')
166        .map(str::trim)
167        .filter(|part| !part.is_empty())
168        .collect();
169    let joined = parts.join("|");
170    joined
171        .trim_end_matches(|c: char| ".,:;\\/|".contains(c))
172        .trim()
173        .to_string()
174}
175
176/// A historical observation recording how intelligence changed across runs.
177#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178pub struct Observation {
179    pub stable_id: String,
180    pub run_id: String,
181    pub subject_type: String,
182    pub subject_id: String,
183    pub kind: String,
184    #[serde(default)]
185    pub before: Value,
186    #[serde(default)]
187    pub after: Value,
188    pub detail: String,
189    pub observed_at: String,
190}
191
192impl Observation {
193    pub fn new(
194        run_id: &str,
195        subject_type: &str,
196        subject_id: &str,
197        kind: &str,
198        before: Value,
199        after: Value,
200        detail: &str,
201    ) -> Self {
202        let before_s = before.to_string();
203        let after_s = after.to_string();
204        let key = format!(
205            "{}:{}:{}:{}:{}:{}",
206            run_id, subject_type, subject_id, kind, before_s, after_s
207        );
208        Self {
209            stable_id: stable_id("observation", &key),
210            run_id: run_id.to_string(),
211            subject_type: subject_type.to_string(),
212            subject_id: subject_id.to_string(),
213            kind: kind.to_string(),
214            before,
215            after,
216            detail: detail.to_string(),
217            observed_at: now_utc(),
218        }
219    }
220}
221
222/// Metadata describing one logical scan run over a target.
223#[derive(Debug, Clone, Serialize, Deserialize, Default)]
224pub struct ScanRun {
225    pub stable_id: String,
226    pub target: String,
227    pub folder: String,
228    pub started_at: Option<String>,
229    pub finished_at: Option<String>,
230    #[serde(default)]
231    pub tools: Vec<String>,
232    #[serde(default)]
233    pub artifacts: Vec<Value>,
234    pub content_hash: String,
235    pub imported_at: String,
236    #[serde(default)]
237    pub stats: Value,
238}
239
240impl ScanRun {
241    pub fn new(
242        run_id: &str,
243        target: &str,
244        folder: &str,
245        content_hash: &str,
246        started_at: Option<String>,
247    ) -> Self {
248        Self {
249            stable_id: run_id.to_string(),
250            target: target.to_string(),
251            folder: folder.to_string(),
252            started_at,
253            finished_at: None,
254            content_hash: content_hash.to_string(),
255            imported_at: now_utc(),
256            stats: Value::Object(Default::default()),
257            ..Default::default()
258        }
259    }
260}
261
262/// A single recorded module execution against a target. History is append-only
263/// so that every attempt — including dry runs, skipped steps, and failures —
264/// is preserved alongside the run it was planned from.
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
266pub struct ExploitExecution {
267    pub execution_id: String,
268    pub run_id: String,
269    pub asset_id: String,
270    pub vulnerability_id: String,
271    pub module_type: String,
272    pub module: String,
273    pub host: String,
274    pub payload: Option<String>,
275    pub status: String,
276    pub start_time: String,
277    pub finish_time: Option<String>,
278    pub job_id: Option<String>,
279    pub session_id: Option<String>,
280    pub error: Option<String>,
281    #[serde(default)]
282    pub loot: Vec<String>,
283    #[serde(default)]
284    pub options: BTreeMap<String, String>,
285    /// Whether the operator selected this step in the checklist.
286    pub selected: bool,
287    pub created_at: String,
288}
289
290impl ExploitExecution {
291    pub fn new(run_id: &str, asset_id: &str, vulnerability_id: &str, module: &str) -> Self {
292        let key = format!("{}:{}:{}:{}", run_id, asset_id, vulnerability_id, module);
293        let now = now_utc();
294        Self {
295            execution_id: sha256_hex(&format!("exploit_exec:{}:{}", key, now)),
296            run_id: run_id.to_string(),
297            asset_id: asset_id.to_string(),
298            vulnerability_id: vulnerability_id.to_string(),
299            module: module.to_string(),
300            module_type: String::new(),
301            host: String::new(),
302            payload: None,
303            status: String::new(),
304            start_time: now.clone(),
305            finish_time: None,
306            job_id: None,
307            session_id: None,
308            error: None,
309            loot: Vec::new(),
310            options: BTreeMap::new(),
311            selected: false,
312            created_at: now,
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn finding_identity_is_stable_across_framing() {
323        let a = Finding::new("asset", "nmap", "Apache Version /");
324        let b = Finding::new("asset", "nmap", "Apache Version | ");
325        assert_eq!(a.stable_id, b.stable_id);
326        let c = Finding::new("asset", "nmap", "Apache Version");
327        assert_eq!(a.stable_id, c.stable_id);
328    }
329
330    #[test]
331    fn finding_identity_keeps_distinct_titles_separate() {
332        let a = Finding::new("asset", "nmap", "Apache 2.4");
333        let b = Finding::new("asset", "nmap", "Apache 2.2");
334        assert_ne!(a.stable_id, b.stable_id);
335    }
336
337    #[test]
338    fn finding_identity_is_scoped_by_tool_and_asset() {
339        let a = Finding::new("asset", "nmap", "Apache");
340        let b = Finding::new("asset", "nikto", "Apache");
341        let c = Finding::new("asset2", "nmap", "Apache");
342        assert_ne!(a.stable_id, b.stable_id);
343        assert_ne!(a.stable_id, c.stable_id);
344    }
345}