Skip to main content

ploit_malper/frontend/
diff.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{json, Value};
3
4use crate::config::ConfigManager;
5use crate::db::models::{Observation, ScanRun};
6use crate::error::{AppError, Result};
7use crate::frontend::{
8    fmt_ts, open_backend, print_json, value_to_short, Args, Cell, IdMaps, Table,
9};
10
11pub fn usage() {
12    println!(
13        "Usage: ploit-malper diff [run-a] [run-b] [--verbose] [--json] \
14         [--backend pocketbase|sqlite]"
15    );
16    println!("       (defaults to comparing the two most recent runs)");
17}
18
19pub fn cmd_diff(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
20    let parsed = Args::parse(args)?;
21    if parsed.has("help") {
22        usage();
23        return Ok(());
24    }
25
26    let mut storage = open_backend(config_mgr, &parsed)?;
27    let runs = storage.list_scan_runs()?;
28    if runs.is_empty() {
29        return Err(AppError::Message(
30            "no scan runs recorded; ingest a folder with 'ploit-malper ingest <folder>' first"
31                .to_string(),
32        ));
33    }
34
35    let (run_a, run_b) = match parsed.positionals() {
36        [] => {
37            if runs.len() < 2 {
38                return Err(AppError::Message(
39                    "only one scan run exists; diff needs two runs (or ingest another folder)"
40                        .to_string(),
41                ));
42            }
43            let mut sorted = runs.clone();
44            sorted.sort_by(|a, b| b.imported_at.cmp(&a.imported_at));
45            (sorted[1].clone(), sorted[0].clone())
46        }
47        [a, b] => {
48            let resolved_a = resolve_run(&runs, a)?;
49            let resolved_b = resolve_run(&runs, b)?;
50            if resolved_a.stable_id == resolved_b.stable_id {
51                return Err(AppError::Message(
52                    "run-a and run-b resolve to the same run".to_string(),
53                ));
54            }
55            (resolved_a.clone(), resolved_b.clone())
56        }
57        _ => {
58            return Err(AppError::Message(
59                "diff accepts zero or two run ids/names".to_string(),
60            ));
61        }
62    };
63
64    let all_observations = storage.list_all_observations()?;
65    let maps = IdMaps::load(&mut storage)?;
66
67    let mut deltas: Vec<DeltaItem> = all_observations
68        .iter()
69        .filter(|o| o.run_id == run_b.stable_id)
70        .map(|o| delta_from_observation(o, &maps))
71        .collect();
72    deltas.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
73
74    let added: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "added").collect();
75    let removed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "removed").collect();
76    let changed: Vec<&DeltaItem> = deltas.iter().filter(|d| d.category == "changed").collect();
77
78    if parsed.has("json") {
79        let payload = json!({
80            "run_a": run_a,
81            "run_b": run_b,
82            "content_changed": run_a.content_hash != run_b.content_hash,
83            "summary": {
84                "added": count_by_type(&added),
85                "removed": count_by_type(&removed),
86                "changed": count_by_type(&changed),
87            },
88            "added": &added,
89            "removed": &removed,
90            "changed": &changed,
91        });
92        return print_json(&payload);
93    }
94
95    if deltas.is_empty() {
96        println!(
97            "No changes between {} and {} (identical content).",
98            run_a.stable_id, run_b.stable_id
99        );
100        return Ok(());
101    }
102
103    println!("Diff: {} -> {}", run_a.stable_id, run_b.stable_id);
104    println!(
105        "  target:    {} ({} target)",
106        run_b.target,
107        if run_a.target == run_b.target {
108            "same"
109        } else {
110            "different"
111        }
112    );
113    println!(
114        "  imported:  {} -> {}",
115        fmt_ts(&run_a.imported_at),
116        fmt_ts(&run_b.imported_at)
117    );
118    println!(
119        "  content:   {}",
120        if run_a.content_hash == run_b.content_hash {
121            "identical".to_string()
122        } else {
123            "changed".to_string()
124        }
125    );
126    println!();
127    println!(
128        "{}",
129        crate::frontend::bold(&format!(
130            "Summary: {} added, {} removed, {} changed",
131            added.len(),
132            removed.len(),
133            changed.len()
134        ))
135    );
136    println!(
137        "  added:   {}",
138        count_by_type(&added)
139            .iter()
140            .map(|(k, v)| format!("{}={}", k, v))
141            .collect::<Vec<_>>()
142            .join(", ")
143    );
144    println!(
145        "  removed: {}",
146        count_by_type(&removed)
147            .iter()
148            .map(|(k, v)| format!("{}={}", k, v))
149            .collect::<Vec<_>>()
150            .join(", ")
151    );
152    println!(
153        "  changed: {}",
154        count_by_type(&changed)
155            .iter()
156            .map(|(k, v)| format!("{}={}", k, v))
157            .collect::<Vec<_>>()
158            .join(", ")
159    );
160
161    render_section("Newly discovered", &added, parsed.has("verbose"));
162    render_section("Removed", &removed, parsed.has("verbose"));
163    render_section("Changed", &changed, parsed.has("verbose"));
164
165    Ok(())
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169struct DeltaItem {
170    category: &'static str,
171    subject_type: String,
172    subject_id: String,
173    label: String,
174    kind: String,
175    before: Value,
176    after: Value,
177    detail: String,
178    observed_at: String,
179}
180
181fn delta_from_observation(obs: &Observation, maps: &IdMaps) -> DeltaItem {
182    let category = if obs.kind.ends_with("_discovered") {
183        "added"
184    } else if obs.kind.ends_with("_removed") {
185        "removed"
186    } else {
187        "changed"
188    };
189    let label = match obs.subject_type.as_str() {
190        "asset" => maps.asset_name(&obs.subject_id),
191        "service" => {
192            let service = maps.services.get(&obs.subject_id);
193            match service {
194                Some(s) => format!("{}:{}", maps.asset_name(&s.asset_id), s.port),
195                None => obs.subject_id.clone(),
196            }
197        }
198        "finding" => maps.finding_title(&obs.subject_id),
199        other => format!("{}:{}", other, obs.subject_id),
200    };
201    DeltaItem {
202        category,
203        subject_type: obs.subject_type.clone(),
204        subject_id: obs.subject_id.clone(),
205        label,
206        kind: obs.kind.clone(),
207        before: obs.before.clone(),
208        after: obs.after.clone(),
209        detail: obs.detail.clone(),
210        observed_at: obs.observed_at.clone(),
211    }
212}
213
214fn count_by_type(items: &[&DeltaItem]) -> Vec<(String, usize)> {
215    let mut counts: Vec<(String, usize)> = Vec::new();
216    for item in items {
217        if let Some(entry) = counts.iter_mut().find(|(key, _)| *key == item.subject_type) {
218            entry.1 += 1;
219        } else {
220            counts.push((item.subject_type.clone(), 1));
221        }
222    }
223    counts.sort();
224    counts
225}
226
227fn render_section(title: &str, items: &[&DeltaItem], verbose: bool) {
228    println!();
229    if items.is_empty() {
230        println!("{}: none", crate::frontend::dim(title));
231        return;
232    }
233    println!("{}: {}", crate::frontend::bold(title), items.len());
234    let mut table = Table::new(vec![
235        "TYPE".to_string(),
236        "SUBJECT".to_string(),
237        "KIND".to_string(),
238        "WHEN".to_string(),
239    ]);
240    for item in items {
241        table.add_row(vec![
242            Cell::plain(item.subject_type.clone()),
243            Cell::plain(item.label.clone()),
244            Cell::plain(item.kind.clone()),
245            Cell::plain(fmt_ts(&item.observed_at)),
246        ]);
247    }
248    println!("{}", table.render());
249    if verbose {
250        for item in items {
251            println!(
252                "  {} {}  [{}]",
253                crate::frontend::dim(&item.subject_type),
254                item.label,
255                item.subject_id
256            );
257            println!("    kind:   {}", item.kind);
258            println!("    detail: {}", item.detail);
259            println!("    before: {}", value_to_short(&item.before));
260            println!("    after:  {}", value_to_short(&item.after));
261        }
262    }
263}
264
265fn resolve_run<'a>(runs: &'a [ScanRun], term: &str) -> Result<&'a ScanRun> {
266    let lower = term.trim().to_lowercase();
267
268    if let Some(run) = runs.iter().find(|r| r.stable_id == lower) {
269        return Ok(run);
270    }
271
272    let prefix_matches: Vec<&ScanRun> = runs
273        .iter()
274        .filter(|r| r.stable_id.starts_with(&lower))
275        .collect();
276    if prefix_matches.len() == 1 {
277        return Ok(prefix_matches[0]);
278    }
279
280    let target_matches: Vec<&ScanRun> = runs
281        .iter()
282        .filter(|r| r.target.to_lowercase() == lower)
283        .collect();
284    if target_matches.len() == 1 {
285        return Ok(target_matches[0]);
286    }
287
288    if !prefix_matches.is_empty() {
289        return Err(AppError::Message(format!(
290            "run '{}' is ambiguous; matches:\n{}",
291            term,
292            prefix_matches
293                .iter()
294                .map(|r| format!("  - {}", r.stable_id))
295                .collect::<Vec<_>>()
296                .join("\n")
297        )));
298    }
299
300    let known: Vec<String> = runs.iter().map(|r| r.stable_id.clone()).collect();
301    Err(AppError::Message(format!(
302        "no run matches '{}'.\nKnown runs:\n{}",
303        term,
304        known
305            .iter()
306            .take(20)
307            .map(|r| format!("  - {}", r))
308            .collect::<Vec<_>>()
309            .join("\n")
310    )))
311}