use crate::config::ConfigManager;
use crate::db::models::Service;
use crate::error::Result;
use crate::frontend::{
empty_result, fmt_ts, open_backend, print_json, Args, Cell, IdMaps, LifecycleQuery, ObsIndex,
Table,
};
pub fn usage() {
println!(
"Usage: ploit-malper services [--port N] [--product TERM] [--asset TERM] \
[--lifecycle STATE|--all] [--verbose] [--json] [--backend pocketbase|sqlite]"
);
}
pub fn cmd_services(args: &[String], config_mgr: &mut ConfigManager) -> Result<()> {
let parsed = Args::parse(args)?;
if parsed.has("help") {
usage();
return Ok(());
}
let mut storage = open_backend(config_mgr, &parsed)?;
let services = storage.list_all_services()?;
let observations = storage.list_all_observations()?;
let obs_index = ObsIndex::build(&observations);
let maps = IdMaps::load(&mut storage)?;
let port_filter = parsed.get("port").map(|p| p.parse::<u16>());
let port_filter = match port_filter {
Some(Ok(port)) => Some(port),
Some(Err(_)) => {
return Err(crate::error::AppError::Message(format!(
"invalid --port value: '{}'",
parsed.get("port").unwrap()
)));
}
None => None,
};
let lifecycle_query = LifecycleQuery::parse(&parsed, false, false)?;
let mut selected: Vec<&Service> = Vec::new();
for service in &services {
if let Some(port) = port_filter {
if service.port != port {
continue;
}
}
if let Some(product) = parsed.get("product") {
let needle = product.to_lowercase();
let hit = service
.product
.as_deref()
.is_some_and(|p| p.to_lowercase().contains(&needle))
|| service
.version
.as_deref()
.is_some_and(|v| v.to_lowercase().contains(&needle))
|| service
.version_str
.as_deref()
.is_some_and(|v| v.to_lowercase().contains(&needle))
|| service.service_name.to_lowercase().contains(&needle);
if !hit {
continue;
}
}
if let Some(asset) = parsed.get("asset") {
let asset_name = maps.asset_name(&service.asset_id);
if !asset_name.to_lowercase().contains(&asset.to_lowercase()) {
continue;
}
}
let lifecycle = obs_index.lifecycle(&service.stable_id, &service.status);
if !lifecycle_query.matches(lifecycle) {
continue;
}
selected.push(service);
}
if parsed.has("json") {
let data: Vec<Service> = selected.into_iter().cloned().collect();
return print_json(&data);
}
if selected.is_empty() {
empty_result("services");
return Ok(());
}
let mut table = Table::new(vec![
"ASSET".to_string(),
"PORT".to_string(),
"PROTO".to_string(),
"SERVICE".to_string(),
"PRODUCT/VERSION".to_string(),
"FIRST SEEN".to_string(),
"LAST SEEN".to_string(),
"CHANGE".to_string(),
]);
for service in &selected {
let lifecycle = obs_index.lifecycle(&service.stable_id, &service.status);
let product_version = match (&service.product, &service.version) {
(Some(p), Some(v)) => format!("{} {}", p, v),
(Some(p), None) => p.clone(),
(None, Some(v)) => v.clone(),
(None, None) => "-".to_string(),
};
table.add_row(vec![
Cell::plain(maps.asset_name(&service.asset_id)),
Cell::plain(service.port.to_string()),
Cell::plain(if service.protocol.is_empty() {
"tcp".to_string()
} else {
service.protocol.clone()
}),
Cell::plain(service.service_name.clone()),
Cell::plain(product_version),
Cell::plain(fmt_ts(&service.first_seen)),
Cell::plain(fmt_ts(&service.last_seen)),
Cell::plain(lifecycle.label().to_string()),
]);
}
println!("{}", table.render());
if parsed.has("verbose") {
println!();
for service in &selected {
println!(
"{}:{} ({}) [{}]",
maps.asset_name(&service.asset_id),
service.port,
service.protocol,
service.stable_id
);
println!(
" service={} product={} version={} version_str={} status={}",
service.service_name,
service.product.as_deref().unwrap_or("-"),
service.version.as_deref().unwrap_or("-"),
service.version_str.as_deref().unwrap_or("-"),
service.status
);
if !service.technologies.is_empty() {
println!(" technologies={}", service.technologies.join(", "));
}
if !service.cpes.is_empty() {
println!(" cpes={}", service.cpes.join(", "));
}
if let Some(banner) = &service.banner {
println!(" banner={}", crate::frontend::truncate(banner, 200));
}
}
}
Ok(())
}