ploit-malper 0.2.6

Post-exploitation analysis and vulnerability intelligence toolkit
Documentation
use crate::config::ConfigManager;
use crate::db::models::ScanRun;
use crate::error::Result;
use crate::frontend::{empty_result, fmt_ts, open_backend, print_json, Args, Cell, Table};

pub fn usage() {
    println!("Usage: ploit-malper runs [--tui] [--verbose] [--json] [--backend pocketbase|sqlite]");
}

pub fn cmd_runs(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 runs = storage.list_scan_runs()?;

    if parsed.has("json") {
        return print_json(&runs);
    }

    if runs.is_empty() {
        empty_result("scan runs");
        println!("Ingest a scan folder with 'ploit-malper ingest <folder>' to create runs.");
        return Ok(());
    }

    if parsed.has("tui") && crate::frontend::tui::interactive() {
        return tui_runs(&runs);
    }

    let mut table = Table::new(vec![
        "RUN ID".to_string(),
        "TARGET".to_string(),
        "STARTED".to_string(),
        "FINISHED".to_string(),
        "ARTIFACTS".to_string(),
        "TOOLS".to_string(),
        "STATS".to_string(),
    ]);
    for run in &runs {
        let artifact_count = run.artifacts.len();
        let stats = stats_summary(run);
        table.add_row(vec![
            Cell::plain(short_id(&run.stable_id)),
            Cell::plain(run.target.clone()),
            Cell::plain(
                run.started_at
                    .as_deref()
                    .map(fmt_ts)
                    .unwrap_or_else(|| "-".to_string()),
            ),
            Cell::plain(
                run.finished_at
                    .as_deref()
                    .map(fmt_ts)
                    .unwrap_or_else(|| "-".to_string()),
            ),
            Cell::plain(artifact_count.to_string()),
            Cell::plain(if run.tools.is_empty() {
                "-".to_string()
            } else {
                run.tools.join(",")
            }),
            Cell::plain(stats),
        ]);
    }
    println!("{}", table.render());

    if parsed.has("verbose") {
        println!();
        for run in &runs {
            println!("{}", crate::frontend::bold(&run.stable_id));
            println!(
                "    target={} folder={} imported_at={}",
                run.target, run.folder, run.imported_at
            );
            println!(
                "    started={} finished={} content_hash={}",
                run.started_at.as_deref().unwrap_or("-"),
                run.finished_at.as_deref().unwrap_or("-"),
                run.content_hash
            );
            println!("    tools={}", run.tools.join(", "));
            for artifact in &run.artifacts {
                println!("    artifact: {}", artifact);
            }
            if !run.stats.is_null() {
                println!("    stats={}", run.stats);
            }
        }
    }

    Ok(())
}

fn short_id(stable_id: &str) -> String {
    crate::frontend::truncate(stable_id, 16)
}

fn tui_runs(runs: &[ScanRun]) -> Result<()> {
    let columns: &[&str] = &["RUN ID", "TARGET", "STARTED", "ARTIFACTS", "TOOLS", "STATS"];
    let rows = runs
        .iter()
        .map(|run| {
            crate::frontend::tui::ViewerRow::new(vec![
                short_id(&run.stable_id),
                run.target.clone(),
                run.started_at
                    .as_deref()
                    .map(fmt_ts)
                    .unwrap_or_else(|| "-".to_string()),
                run.artifacts.len().to_string(),
                if run.tools.is_empty() {
                    "-".to_string()
                } else {
                    run.tools.join(",")
                },
                stats_summary(run),
            ])
        })
        .collect();
    let _ = crate::frontend::tui::view_table("Scan runs", columns, rows)?;
    Ok(())
}

fn stats_summary(run: &ScanRun) -> String {
    if run.stats.is_null() {
        return "-".to_string();
    }
    let obj = match run.stats.as_object() {
        Some(o) => o,
        None => return "-".to_string(),
    };
    let get = |key: &str| {
        obj.get(key)
            .and_then(serde_json::Value::as_u64)
            .map(|v| v.to_string())
            .unwrap_or_else(|| "?".to_string())
    };
    format!(
        "a:{}/s:{}/f:{}/o:{}",
        get("assets"),
        get("services"),
        get("findings"),
        get("observations")
    )
}