ploit_malper/frontend/
assets.rs1use crate::config::ConfigManager;
2use crate::db::models::Asset;
3use crate::error::{AppError, Result};
4use crate::frontend::{
5 bold, fmt_ts, open_backend, parse_ts, print_json, ts_since, Args, Cell, IdMaps, Lifecycle,
6 LifecycleQuery, ObsIndex, Table,
7};
8
9pub fn usage() {
10 println!(
11 "Usage: ploit-malper assets [--lifecycle STATE|--all] [--new] [--changed] [--since DATE] \
12 [--target TERM] [--verbose] [--json] [--backend pocketbase|sqlite]"
13 );
14}
15
16pub fn cmd_assets(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
17 let parsed = Args::parse(args)?;
18 if parsed.has("help") {
19 usage();
20 return Ok(());
21 }
22
23 let mut storage = open_backend(config_mgr, &parsed)?;
24
25 let assets = storage.list_assets()?;
26 let observations = storage.list_all_observations()?;
27 let obs_index = ObsIndex::build(&observations);
28 let maps = IdMaps::load(&mut storage)?;
29
30 let since = match parsed.get("since") {
31 Some(raw) => Some(
32 parse_ts(raw)
33 .ok_or_else(|| AppError::Message(format!("invalid --since date: '{}'", raw)))?,
34 ),
35 None => None,
36 };
37
38 let lifecycle_query = LifecycleQuery::parse(&parsed, true, false)?;
39
40 let mut selected: Vec<&Asset> = Vec::new();
41 for asset in &assets {
42 if let Some(target) = parsed.get("target") {
43 let needle = target.to_lowercase();
44 let hit = asset.name.to_lowercase().contains(&needle)
45 || asset
46 .ip
47 .as_deref()
48 .is_some_and(|i| i.to_lowercase().contains(&needle))
49 || asset
50 .fqdn
51 .as_deref()
52 .is_some_and(|f| f.to_lowercase().contains(&needle))
53 || asset
54 .reverse_dns
55 .as_deref()
56 .is_some_and(|r| r.to_lowercase().contains(&needle));
57 if !hit {
58 continue;
59 }
60 }
61 if let Some(since) = since {
62 if !ts_since(&asset.last_seen, since) {
63 continue;
64 }
65 }
66 let lifecycle = obs_index.lifecycle(&asset.stable_id, &asset.status);
67 if !lifecycle_query.matches(lifecycle) {
68 continue;
69 }
70 selected.push(asset);
71 }
72
73 if parsed.has("json") {
74 let data: Vec<Asset> = selected.into_iter().cloned().collect();
75 return print_json(&data);
76 }
77
78 if selected.is_empty() {
79 crate::frontend::empty_result("assets");
80 return Ok(());
81 }
82
83 let mut table = Table::new(vec![
84 "NAME".to_string(),
85 "TYPE".to_string(),
86 "SERVICES".to_string(),
87 "FIRST SEEN".to_string(),
88 "LAST SEEN".to_string(),
89 "CHANGE".to_string(),
90 ]);
91 for asset in &selected {
92 let lifecycle = obs_index.lifecycle(&asset.stable_id, &asset.status);
93 table.add_row(vec![
94 Cell::plain(asset.name.clone()),
95 Cell::plain(asset.asset_type.clone()),
96 Cell::plain(maps.service_summary(&asset.stable_id)),
97 Cell::plain(fmt_ts(&asset.first_seen)),
98 Cell::plain(fmt_ts(&asset.last_seen)),
99 Cell::styled(lifecycle.label().to_string(), lifecycle_style(lifecycle)),
100 ]);
101 }
102 println!("{}", table.render());
103
104 if parsed.has("verbose") {
105 println!();
106 for asset in &selected {
107 println!(
108 "{} ({}) [{}]",
109 bold(&asset.name),
110 asset.asset_type,
111 asset.stable_id
112 );
113 println!(
114 " ip={} fqdn={} reverse_dns={} status={}",
115 asset.ip.as_deref().unwrap_or("-"),
116 asset.fqdn.as_deref().unwrap_or("-"),
117 asset.reverse_dns.as_deref().unwrap_or("-"),
118 asset.status
119 );
120 if !asset.metadata.is_null() && asset.metadata.as_object().is_some() {
121 println!(" metadata={}", asset.metadata);
122 }
123 let observations = obs_index.observations_for(&asset.stable_id);
124 if !observations.is_empty() {
125 let last = observations.last().unwrap();
126 println!(" last change: {} ({})", last.kind, last.observed_at);
127 }
128 }
129 }
130
131 Ok(())
132}
133
134fn lifecycle_style(lifecycle: Lifecycle) -> &'static str {
135 match lifecycle {
136 Lifecycle::New => "32",
137 Lifecycle::Changed => "33",
138 Lifecycle::Removed => "31",
139 Lifecycle::Active => "2",
140 }
141}