Skip to main content

ploit_malper/frontend/
mod.rs

1pub mod assets;
2pub mod diff;
3pub mod findings;
4pub mod history;
5pub mod runs;
6pub mod services;
7pub mod tui;
8
9use std::collections::{HashMap, HashSet};
10use std::io::{self, IsTerminal};
11
12use serde_json::Value;
13use time::OffsetDateTime;
14
15use crate::config::ConfigManager;
16use crate::db::models::{Asset, Finding, Observation, Service};
17use crate::db::Storage;
18use crate::error::{AppError, Result};
19
20// ---------------------------------------------------------------------------
21// Command-line argument parsing
22// ---------------------------------------------------------------------------
23
24const BOOLEAN_FLAGS: &[&str] = &[
25    "verbose",
26    "json",
27    "new",
28    "changed",
29    "fixed",
30    "help",
31    "dry-run",
32    "yes",
33    "tui",
34    "include-info",
35    "all",
36];
37const VALUE_FLAGS: &[&str] = &[
38    "target",
39    "since",
40    "port",
41    "product",
42    "asset",
43    "severity",
44    "cve",
45    "backend",
46    "pocketbase-url",
47    "sqlite-path",
48    "module",
49    "payload",
50    "workspace",
51    "job-timeout",
52    "status",
53    "lifecycle",
54];
55
56/// Minimal, consistent flag parser shared by all frontend commands.
57#[derive(Debug, Default, Clone)]
58pub struct Args {
59    flags: HashSet<String>,
60    values: HashMap<String, String>,
61    positionals: Vec<String>,
62}
63
64impl Args {
65    pub fn parse(args: &[String]) -> Result<Self> {
66        let mut flags = HashSet::new();
67        let mut values = HashMap::new();
68        let mut positionals = Vec::new();
69
70        let mut i = 0usize;
71        while i < args.len() {
72            let arg = &args[i];
73            if let Some(rest) = arg.strip_prefix("--") {
74                if let Some((key, value)) = rest.split_once('=') {
75                    if VALUE_FLAGS.contains(&key) {
76                        values.insert(key.to_string(), value.to_string());
77                        i += 1;
78                        continue;
79                    }
80                    return Err(AppError::Message(format!(
81                        "unknown option: --{} (use --{} VALUE)",
82                        key, key
83                    )));
84                }
85                if VALUE_FLAGS.contains(&rest) {
86                    let value = args.get(i + 1).ok_or_else(|| {
87                        AppError::Message(format!("option --{} requires a value", rest))
88                    })?;
89                    values.insert(rest.to_string(), value.clone());
90                    i += 2;
91                    continue;
92                }
93                if BOOLEAN_FLAGS.contains(&rest) {
94                    flags.insert(rest.to_string());
95                    i += 1;
96                    continue;
97                }
98                return Err(AppError::Message(format!("unknown option: --{}", rest)));
99            } else if arg.starts_with('-') && arg.len() > 1 {
100                let short = &arg[1..];
101                match short {
102                    "v" => {
103                        flags.insert("verbose".to_string());
104                    }
105                    "h" => {
106                        flags.insert("help".to_string());
107                    }
108                    _ => {
109                        return Err(AppError::Message(format!("unknown option: -{}", short)));
110                    }
111                }
112                i += 1;
113                continue;
114            }
115            positionals.push(arg.clone());
116            i += 1;
117        }
118
119        Ok(Self {
120            flags,
121            values,
122            positionals,
123        })
124    }
125
126    pub fn has(&self, name: &str) -> bool {
127        self.flags.contains(name)
128    }
129
130    pub fn get(&self, name: &str) -> Option<&str> {
131        self.values.get(name).map(|s| s.as_str())
132    }
133
134    pub fn positionals(&self) -> &[String] {
135        &self.positionals
136    }
137}
138
139// ---------------------------------------------------------------------------
140// Colour helpers (terminal friendly, honour NO_COLOR / non-tty output)
141// ---------------------------------------------------------------------------
142
143pub fn color_enabled() -> bool {
144    if std::env::var_os("NO_COLOR").is_some() {
145        return false;
146    }
147    io::stdout().is_terminal()
148}
149
150fn paint(text: &str, code: &str) -> String {
151    if color_enabled() {
152        format!("\x1b[{}m{}\x1b[0m", code, text)
153    } else {
154        text.to_string()
155    }
156}
157
158pub fn red(text: &str) -> String {
159    paint(text, "31")
160}
161
162pub fn green(text: &str) -> String {
163    paint(text, "32")
164}
165
166pub fn yellow(text: &str) -> String {
167    paint(text, "33")
168}
169
170pub fn cyan(text: &str) -> String {
171    paint(text, "36")
172}
173
174pub fn dim(text: &str) -> String {
175    paint(text, "2")
176}
177
178pub fn bold(text: &str) -> String {
179    paint(text, "1")
180}
181
182pub fn severity_style(severity: &str) -> &'static str {
183    match severity.to_lowercase().as_str() {
184        "critical" => "1;31",
185        "high" => "31",
186        "medium" => "33",
187        "low" => "34",
188        "info" | "none" => "2",
189        _ => "0",
190    }
191}
192
193pub fn severity_label(severity: &str) -> String {
194    severity.to_uppercase()
195}
196
197/// Rank used to sort severities from most to least severe.
198pub fn severity_rank(severity: &str) -> u8 {
199    match severity.to_lowercase().as_str() {
200        "critical" => 0,
201        "high" => 1,
202        "medium" => 2,
203        "low" => 3,
204        "info" => 4,
205        "none" => 5,
206        _ => 9,
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Table rendering
212// ---------------------------------------------------------------------------
213
214pub struct Cell {
215    pub text: String,
216    pub style: Option<&'static str>,
217}
218
219impl Cell {
220    pub fn plain(text: String) -> Self {
221        Self { text, style: None }
222    }
223
224    pub fn styled(text: String, style: &'static str) -> Self {
225        Self {
226            text,
227            style: Some(style),
228        }
229    }
230}
231
232pub struct Table {
233    headers: Vec<String>,
234    rows: Vec<Vec<Cell>>,
235    max_width: usize,
236}
237
238impl Table {
239    pub fn new(headers: Vec<String>) -> Self {
240        Self {
241            headers,
242            rows: Vec::new(),
243            max_width: 60,
244        }
245    }
246
247    pub fn add_row(&mut self, cells: Vec<Cell>) {
248        self.rows.push(cells);
249    }
250
251    pub fn render(&self) -> String {
252        let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
253        for row in &self.rows {
254            for (i, cell) in row.iter().enumerate() {
255                if i < widths.len() {
256                    widths[i] = widths[i].max(cell.text.chars().count());
257                }
258            }
259        }
260        for w in widths.iter_mut() {
261            *w = (*w).min(self.max_width);
262        }
263
264        let mut out = String::new();
265        let header_cells: Vec<String> = self
266            .headers
267            .iter()
268            .enumerate()
269            .map(|(i, h)| pad(&truncate(h, widths[i]), widths[i]))
270            .collect();
271        out.push_str(&header_cells.join("  "));
272        out.push('\n');
273
274        out.push_str(
275            &widths
276                .iter()
277                .map(|w| "-".repeat(*w))
278                .collect::<Vec<_>>()
279                .join("  "),
280        );
281        out.push('\n');
282
283        for row in &self.rows {
284            let mut line = String::new();
285            for (i, cell) in row.iter().enumerate() {
286                if i > 0 {
287                    line.push_str("  ");
288                }
289                let width = widths.get(i).copied().unwrap_or(self.max_width);
290                let padded = pad(&truncate(&cell.text, width), width);
291                line.push_str(&match cell.style {
292                    Some(code) => paint(&padded, code),
293                    None => padded,
294                });
295            }
296            out.push_str(&line);
297            out.push('\n');
298        }
299        out
300    }
301}
302
303fn pad(text: &str, width: usize) -> String {
304    let len = text.chars().count();
305    if len >= width {
306        return text.to_string();
307    }
308    let mut out = text.to_string();
309    out.extend(std::iter::repeat_n(' ', width - len));
310    out
311}
312
313pub fn truncate(value: &str, width: usize) -> String {
314    let count = value.chars().count();
315    if count <= width {
316        return value.to_string();
317    }
318    if width <= 1 {
319        return "…".to_string();
320    }
321    let mut out: String = value.chars().take(width - 1).collect();
322    out.push('…');
323    out
324}
325
326// ---------------------------------------------------------------------------
327// Timestamp helpers
328// ---------------------------------------------------------------------------
329
330fn parse_date_only(s: &str) -> Option<OffsetDateTime> {
331    let mut parts = s.split('-');
332    let year: i32 = parts.next()?.parse().ok()?;
333    let month: u8 = parts.next()?.parse().ok()?;
334    let day: u8 = parts.next()?.parse().ok()?;
335    if parts.next().is_some() {
336        return None;
337    }
338    let date =
339        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
340    Some(date.midnight().assume_utc())
341}
342
343/// Matches `YYYY-MM-DD HH:MM:SS(.fff)? (Z|+HH:MM(:SS)?|...)` in both the
344/// standard RFC3339 shape and the space-separated `OffsetDateTime::to_string()`
345/// shape produced by the importer.
346fn parse_flexible(s: &str) -> Option<OffsetDateTime> {
347    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
348    let re = RE.get_or_init(|| {
349        regex::Regex::new(
350            r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}(?::\d{2})?))?$",
351        )
352        .expect("valid timestamp regex")
353    });
354
355    let caps = re.captures(s.trim())?;
356    let year: i32 = caps.get(1)?.as_str().parse().ok()?;
357    let month: u8 = caps.get(2)?.as_str().parse().ok()?;
358    let day: u8 = caps.get(3)?.as_str().parse().ok()?;
359    let hour: u8 = caps.get(4)?.as_str().parse().ok()?;
360    let minute: u8 = caps.get(5)?.as_str().parse().ok()?;
361    let second: u8 = caps
362        .get(6)
363        .map(|m| m.as_str().parse().ok())
364        .unwrap_or(Some(0))?;
365
366    let date =
367        time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
368    let time = time::Time::from_hms(hour, minute, second).ok()?;
369
370    match caps.get(7).map(|m| m.as_str()) {
371        None | Some("Z") => Some(date.with_time(time).assume_utc()),
372        Some(offset) => {
373            let sign: i8 = if offset.starts_with('-') { -1 } else { 1 };
374            let digits = offset.trim_start_matches(['+', '-']);
375            let mut parts = digits.split(':');
376            let oh: i8 = parts.next()?.parse().ok()?;
377            let om: i8 = parts.next()?.parse().ok()?;
378            let utc_offset = time::UtcOffset::from_hms(sign * oh, sign * om, 0).ok()?;
379            Some(date.with_time(time).assume_offset(utc_offset))
380        }
381    }
382}
383
384/// Parse RFC3339, the importer's space-separated timestamp shape, `YYYY-MM-DD
385/// HH:MM:SS UTC`, `YYYY-MM-DD`, or a unix epoch.
386pub fn parse_ts(value: &str) -> Option<OffsetDateTime> {
387    let trimmed = value.trim();
388    if let Ok(dt) = OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339) {
389        return Some(dt);
390    }
391    let normalised = trimmed.replace(" UTC", "Z").replace(' ', "T");
392    if let Ok(dt) =
393        OffsetDateTime::parse(&normalised, &time::format_description::well_known::Rfc3339)
394    {
395        return Some(dt);
396    }
397    if let Some(dt) = parse_flexible(trimmed) {
398        return Some(dt);
399    }
400    if let Some(dt) = parse_date_only(trimmed) {
401        return Some(dt);
402    }
403    if let Ok(epoch) = trimmed.parse::<i64>() {
404        if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch) {
405            return Some(dt);
406        }
407    }
408    None
409}
410
411/// Compact `YYYY-MM-DD HH:MM` (UTC) representation for tables.
412pub fn fmt_ts(value: &str) -> String {
413    if value.is_empty() {
414        return "-".to_string();
415    }
416    match parse_ts(value) {
417        Some(dt) => {
418            let utc = dt.to_offset(time::UtcOffset::UTC);
419            format!(
420                "{:04}-{:02}-{:02} {:02}:{:02}",
421                utc.year(),
422                u8::from(utc.month()),
423                utc.day(),
424                utc.hour(),
425                utc.minute()
426            )
427        }
428        None => truncate(value, 16).to_string(),
429    }
430}
431
432/// True if `ts` is at or after `since`.
433pub fn ts_since(ts: &str, since: OffsetDateTime) -> bool {
434    match parse_ts(ts) {
435        Some(dt) => dt >= since,
436        None => false,
437    }
438}
439
440// ---------------------------------------------------------------------------
441// Backend access
442// ---------------------------------------------------------------------------
443
444/// Open the configured backend, honouring `--backend` / `--pocketbase-url` /
445/// `--sqlite-path` overrides, and fail clearly when unconfigured/unreachable.
446pub fn open_backend(config_mgr: &ConfigManager, args: &Args) -> Result<Box<dyn Storage>> {
447    crate::db::pocketbase::set_verbose(args.has("verbose"));
448    let db = config_mgr.get_database_config().clone();
449    if !db.configured {
450        return Err(AppError::Message(
451            "no database configured; run 'ploit-malper db_setup' first".to_string(),
452        ));
453    }
454    let backend = args.get("backend").unwrap_or(db.backend.as_str());
455    let mut storage = crate::db::open_storage_with(
456        &db,
457        backend,
458        args.get("pocketbase-url"),
459        args.get("sqlite-path"),
460    )?;
461    if !storage.ping()? {
462        return Err(AppError::Message(format!(
463            "{} backend is not reachable",
464            storage.kind()
465        )));
466    }
467    Ok(storage)
468}
469
470// ---------------------------------------------------------------------------
471// Id -> name maps
472// ---------------------------------------------------------------------------
473
474/// Lookup tables so foreign keys (asset_id, service_id) can be resolved to
475/// human-readable names across every frontend command.
476pub struct IdMaps {
477    pub assets: HashMap<String, Asset>,
478    pub services: HashMap<String, Service>,
479    pub findings: HashMap<String, Finding>,
480}
481
482impl IdMaps {
483    pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
484        let assets = storage
485            .list_assets()?
486            .into_iter()
487            .map(|a| (a.stable_id.clone(), a))
488            .collect();
489        let services = storage
490            .list_all_services()?
491            .into_iter()
492            .map(|s| (s.stable_id.clone(), s))
493            .collect();
494        let findings = storage
495            .list_all_findings()?
496            .into_iter()
497            .map(|f| (f.stable_id.clone(), f))
498            .collect();
499        Ok(Self {
500            assets,
501            services,
502            findings,
503        })
504    }
505
506    pub fn asset_name(&self, id: &str) -> String {
507        self.assets
508            .get(id)
509            .map(|a| a.name.clone())
510            .unwrap_or_else(|| id.to_string())
511    }
512
513    pub fn service_label(&self, id: &str) -> String {
514        match self.services.get(id) {
515            Some(s) => format!(
516                "{}/{}",
517                s.port,
518                if s.protocol.is_empty() {
519                    "tcp"
520                } else {
521                    &s.protocol
522                }
523            ),
524            None => id.to_string(),
525        }
526    }
527
528    pub fn service_summary(&self, asset_id: &str) -> String {
529        let mut services: Vec<&Service> = self
530            .services
531            .values()
532            .filter(|s| s.asset_id == asset_id && s.status != "removed")
533            .collect();
534        services.sort_by_key(|s| s.port);
535        if services.is_empty() {
536            return "-".to_string();
537        }
538        format!(
539            "{} open ({})",
540            services.len(),
541            services
542                .iter()
543                .map(|s| s.port.to_string())
544                .collect::<Vec<_>>()
545                .join(",")
546        )
547    }
548
549    pub fn finding_title(&self, id: &str) -> String {
550        self.findings
551            .get(id)
552            .map(|f| f.title.clone())
553            .unwrap_or_else(|| id.to_string())
554    }
555}
556
557// ---------------------------------------------------------------------------
558// Observation-derived lifecycle state
559// ---------------------------------------------------------------------------
560
561/// First-class lifecycle of a record, derived from its observations and its
562/// stored status. `Active` and `Unchanged` are the same lifecycle (present and
563/// unchanged in the latest run); the `Unchanged` alias exists so callers and
564/// CLI flags can spell it either way.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum Lifecycle {
567    New,
568    Active,
569    Changed,
570    Removed,
571}
572
573/// Backward-compatible finite state for observation classification. Kept as
574/// a separate type so `ObsIndex::state` semantics stay intact; `Lifecycle`
575/// is the first-class projection used by the CLI.
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577pub enum ChangeState {
578    New,
579    Changed,
580    Removed,
581    Unchanged,
582}
583
584impl ChangeState {
585    pub fn label(&self) -> &'static str {
586        match self {
587            ChangeState::New => "new",
588            ChangeState::Changed => "changed",
589            ChangeState::Removed => "removed",
590            ChangeState::Unchanged => "active",
591        }
592    }
593
594    pub fn paint(&self, text: &str) -> String {
595        match self {
596            ChangeState::New => green(text),
597            ChangeState::Changed => yellow(text),
598            ChangeState::Removed => red(text),
599            ChangeState::Unchanged => dim(text),
600        }
601    }
602}
603
604/// Recognised lifecycle spellings accepted by CLI filters.
605pub const LIFECYCLE_NAMES: &[&str] = &["new", "active", "unchanged", "changed", "removed", "fixed"];
606
607impl Lifecycle {
608    pub fn label(&self) -> &'static str {
609        match self {
610            Lifecycle::New => "new",
611            Lifecycle::Active => "active",
612            Lifecycle::Changed => "changed",
613            Lifecycle::Removed => "removed",
614        }
615    }
616
617    /// Combine the stored lifecycle status with the state recorded this run.
618    pub fn resolve(status: &str, run_state: Self) -> Self {
619        if status == crate::db::models::ASSET_REMOVED {
620            return Lifecycle::Removed;
621        }
622        run_state
623    }
624
625    /// True when `name` denotes this lifecycle. `active` and `unchanged` are
626    /// interchangeable; `fixed` is a synonym for `removed` in findings UI.
627    pub fn matches(&self, name: &str) -> bool {
628        match name {
629            "new" => matches!(self, Lifecycle::New),
630            "active" | "unchanged" => matches!(self, Lifecycle::Active),
631            "changed" => matches!(self, Lifecycle::Changed),
632            "removed" | "fixed" => matches!(self, Lifecycle::Removed),
633            _ => false,
634        }
635    }
636
637    pub fn paint(&self, text: &str) -> String {
638        match self {
639            Lifecycle::New => green(text),
640            Lifecycle::Changed => yellow(text),
641            Lifecycle::Removed => red(text),
642            Lifecycle::Active => dim(text),
643        }
644    }
645}
646
647impl From<ChangeState> for Lifecycle {
648    fn from(state: ChangeState) -> Self {
649        match state {
650            ChangeState::New => Lifecycle::New,
651            ChangeState::Changed => Lifecycle::Changed,
652            ChangeState::Removed => Lifecycle::Removed,
653            ChangeState::Unchanged => Lifecycle::Active,
654        }
655    }
656}
657
658impl From<LifecycleFilter> for Lifecycle {
659    fn from(filter: LifecycleFilter) -> Self {
660        match filter {
661            LifecycleFilter::New => Lifecycle::New,
662            LifecycleFilter::Active => Lifecycle::Active,
663            LifecycleFilter::Changed => Lifecycle::Changed,
664            LifecycleFilter::Removed => Lifecycle::Removed,
665        }
666    }
667}
668
669pub fn classify_kind(kind: &str) -> ChangeState {
670    if kind.ends_with("_discovered") {
671        ChangeState::New
672    } else if kind.ends_with("_removed") {
673        ChangeState::Removed
674    } else if kind.contains("changed") {
675        ChangeState::Changed
676    } else {
677        ChangeState::Unchanged
678    }
679}
680
681/// Index of observations by subject id for change-state lookups.
682pub struct ObsIndex {
683    by_subject: HashMap<String, Vec<Observation>>,
684    latest_run: String,
685}
686
687impl ObsIndex {
688    pub fn build(all: &[Observation]) -> Self {
689        let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
690        for obs in all {
691            by_subject
692                .entry(obs.subject_id.clone())
693                .or_default()
694                .push(obs.clone());
695        }
696        for values in by_subject.values_mut() {
697            values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
698        }
699        let latest_run = all
700            .iter()
701            .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
702            .map(|o| o.run_id.clone())
703            .unwrap_or_default();
704        Self {
705            by_subject,
706            latest_run,
707        }
708    }
709
710    pub fn observations_for(&self, id: &str) -> &[Observation] {
711        self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
712    }
713
714    /// The change state of a record as of the most recent run. `status` is the
715    /// record's stored lifecycle status (used to flag removals that predate
716    /// the latest run).
717    pub fn state(&self, id: &str, status: &str) -> ChangeState {
718        if status == crate::db::models::ASSET_REMOVED {
719            return ChangeState::Removed;
720        }
721        let last = self
722            .observations_for(id)
723            .iter()
724            .rev()
725            .find(|o| o.run_id == self.latest_run);
726        match last {
727            Some(obs) => classify_kind(&obs.kind),
728            None => ChangeState::Unchanged,
729        }
730    }
731
732    /// First-class lifecycle for a record combining stored status and the
733    /// most recent run's observation.
734    pub fn lifecycle(&self, id: &str, status: &str) -> Lifecycle {
735        Lifecycle::resolve(status, self.state(id, status).into())
736    }
737
738    pub fn latest_run(&self) -> &str {
739        &self.latest_run
740    }
741}
742
743// ---------------------------------------------------------------------------
744// Subject resolution (history)
745// ---------------------------------------------------------------------------
746
747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
748pub enum SubjectKind {
749    Asset,
750    Service,
751    Finding,
752}
753
754impl SubjectKind {
755    pub fn label(&self) -> &'static str {
756        match self {
757            SubjectKind::Asset => "asset",
758            SubjectKind::Service => "service",
759            SubjectKind::Finding => "finding",
760        }
761    }
762}
763
764pub struct ResolvedSubject {
765    pub kind: SubjectKind,
766    pub id: String,
767    pub label: String,
768}
769
770fn list_some(items: &[String], cap: usize) -> String {
771    items
772        .iter()
773        .take(cap)
774        .map(|s| format!("  - {}", s))
775        .collect::<Vec<_>>()
776        .join("\n")
777}
778
779/// Resolve a user-supplied id or name to a specific asset, service, or
780/// finding. Supports stable ids, exact names/titles, `asset:port` for
781/// services, and unambiguous substring matches.
782pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
783    let lower = term.trim().to_lowercase();
784    if lower.is_empty() {
785        return Err(AppError::Message(
786            "history requires an asset, service, or finding id/name".to_string(),
787        ));
788    }
789
790    if let Some(asset) = maps.assets.get(&lower) {
791        return Ok(ResolvedSubject {
792            kind: SubjectKind::Asset,
793            id: asset.stable_id.clone(),
794            label: asset.name.clone(),
795        });
796    }
797    if let Some(service) = maps.services.get(&lower) {
798        return Ok(ResolvedSubject {
799            kind: SubjectKind::Service,
800            id: service.stable_id.clone(),
801            label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
802        });
803    }
804    if let Some(finding) = maps.findings.get(&lower) {
805        return Ok(ResolvedSubject {
806            kind: SubjectKind::Finding,
807            id: finding.stable_id.clone(),
808            label: finding.title.clone(),
809        });
810    }
811
812    let asset_exact: Vec<&Asset> = maps
813        .assets
814        .values()
815        .filter(|a| {
816            a.name.eq_ignore_ascii_case(&lower)
817                || a.ip
818                    .as_deref()
819                    .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
820                || a.fqdn
821                    .as_deref()
822                    .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
823                || a.reverse_dns
824                    .as_deref()
825                    .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
826        })
827        .collect();
828    if asset_exact.len() == 1 {
829        let a = asset_exact[0];
830        return Ok(ResolvedSubject {
831            kind: SubjectKind::Asset,
832            id: a.stable_id.clone(),
833            label: a.name.clone(),
834        });
835    }
836
837    let finding_exact: Vec<&Finding> = maps
838        .findings
839        .values()
840        .filter(|f| f.title.eq_ignore_ascii_case(&lower))
841        .collect();
842    if finding_exact.len() == 1 {
843        let f = finding_exact[0];
844        return Ok(ResolvedSubject {
845            kind: SubjectKind::Finding,
846            id: f.stable_id.clone(),
847            label: f.title.clone(),
848        });
849    }
850
851    // asset:port -> service
852    if let Some((name_part, port_part)) = lower.rsplit_once(':') {
853        if let Ok(port) = port_part.parse::<u16>() {
854            if let Some(asset) = maps
855                .assets
856                .values()
857                .find(|a| a.name.eq_ignore_ascii_case(name_part))
858            {
859                if let Some(service) = maps
860                    .services
861                    .values()
862                    .find(|s| s.asset_id == asset.stable_id && s.port == port)
863                {
864                    return Ok(ResolvedSubject {
865                        kind: SubjectKind::Service,
866                        id: service.stable_id.clone(),
867                        label: format!("{}:{}", asset.name, service.port),
868                    });
869                }
870            }
871        }
872    }
873
874    // Unambiguous substring matches.
875    let asset_contains: Vec<String> = maps
876        .assets
877        .values()
878        .filter(|a| {
879            a.name.to_lowercase().contains(&lower)
880                || a.ip
881                    .as_deref()
882                    .is_some_and(|i| i.to_lowercase().contains(&lower))
883                || a.fqdn
884                    .as_deref()
885                    .is_some_and(|f| f.to_lowercase().contains(&lower))
886        })
887        .map(|a| a.name.clone())
888        .collect();
889    if asset_contains.len() == 1 {
890        let name = &asset_contains[0];
891        let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
892        return Ok(ResolvedSubject {
893            kind: SubjectKind::Asset,
894            id: asset.stable_id.clone(),
895            label: asset.name.clone(),
896        });
897    }
898
899    let finding_contains: Vec<String> = maps
900        .findings
901        .values()
902        .filter(|f| f.title.to_lowercase().contains(&lower))
903        .map(|f| f.title.clone())
904        .collect();
905    if finding_contains.len() == 1 {
906        let title = &finding_contains[0];
907        let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
908        return Ok(ResolvedSubject {
909            kind: SubjectKind::Finding,
910            id: finding.stable_id.clone(),
911            label: finding.title.clone(),
912        });
913    }
914
915    if !asset_contains.is_empty() || !finding_contains.is_empty() {
916        let mut lines = Vec::new();
917        if !asset_contains.is_empty() {
918            lines.push(format!("Assets matching '{}':", term));
919            lines.push(list_some(&asset_contains, 5));
920        }
921        if !finding_contains.is_empty() {
922            lines.push(format!("Findings matching '{}':", term));
923            lines.push(list_some(&finding_contains, 5));
924        }
925        lines.push("Be more specific, or use a full id.".to_string());
926        return Err(AppError::Message(lines.join("\n")));
927    }
928
929    Err(AppError::Message(format!(
930        "no asset, service, or finding matches '{}'",
931        term
932    )))
933}
934
935// ---------------------------------------------------------------------------
936// JSON output
937// ---------------------------------------------------------------------------
938
939pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
940    println!("{}", serde_json::to_string_pretty(value)?);
941    Ok(())
942}
943
944/// Print a "nothing matched" message consistent across commands.
945pub fn empty_result(what: &str) {
946    println!("No {} found.", what);
947}
948
949/// Convert a `Value` to a compact single-line rendering for verbose output.
950pub fn value_to_short(value: &Value) -> String {
951    match value {
952        Value::Null => "-".to_string(),
953        Value::String(s) => truncate(s, 80).to_string(),
954        other => truncate(&other.to_string(), 120).to_string(),
955    }
956}
957
958/// A parsed lifecycle filter (`--lifecycle`/`--status`) from the CLI.
959#[derive(Debug, Clone, Copy, PartialEq, Eq)]
960pub enum LifecycleFilter {
961    New,
962    Active,
963    Changed,
964    Removed,
965}
966
967impl LifecycleFilter {
968    /// Parse a lifecycle term to a filter. Returns a clear error for
969    /// unrecognised spellings so commands fail loudly instead of silently
970    /// showing nothing.
971    pub fn parse_term(raw: &str) -> Result<Self> {
972        match raw.to_lowercase().as_str() {
973            "new" => Ok(LifecycleFilter::New),
974            "active" | "unchanged" => Ok(LifecycleFilter::Active),
975            "changed" => Ok(LifecycleFilter::Changed),
976            "removed" | "fixed" => Ok(LifecycleFilter::Removed),
977            other => Err(AppError::Message(format!(
978                "unknown lifecycle '{}' (use one of: {})",
979                other,
980                LIFECYCLE_NAMES.join(", ")
981            ))),
982        }
983    }
984
985    pub fn matches(&self, lifecycle: Lifecycle) -> bool {
986        match self {
987            LifecycleFilter::New => matches!(lifecycle, Lifecycle::New),
988            LifecycleFilter::Active => matches!(lifecycle, Lifecycle::Active),
989            LifecycleFilter::Changed => matches!(lifecycle, Lifecycle::Changed),
990            LifecycleFilter::Removed => matches!(lifecycle, Lifecycle::Removed),
991        }
992    }
993}
994
995/// The lifecycle view requested by the CLI. This is the single query model
996/// shared by the list commands (findings, assets, services): `--all` requests
997/// every lifecycle including removed/fixed; the default view hides long-gone
998/// records; an explicit `--lifecycle`/`--status`/legacy singleton restricts to
999/// one state.
1000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1001pub enum LifecycleQuery {
1002    /// Show every lifecycle state, including removed/fixed (`--all`).
1003    All,
1004    /// Restrict to one lifecycle state (`--lifecycle`/`--status`/`--new`/…).
1005    Filtered(LifecycleFilter),
1006    /// Default view: every record except removed/fixed.
1007    Default,
1008}
1009
1010impl LifecycleQuery {
1011    /// Parse the shared query from CLI args. Legacy singletons (`--new`,
1012    /// `--fixed`, `--changed`) map to the equivalent lifecycle so all commands
1013    /// share one query model; `override_new`/`override_fixed` scope whether a
1014    /// view supports the `--new`/`--fixed` spellings.
1015    pub fn parse(args: &Args, override_new: bool, override_fixed: bool) -> Result<Self> {
1016        if let Some(raw) = args.get("lifecycle").or_else(|| args.get("status")) {
1017            return Ok(LifecycleQuery::Filtered(LifecycleFilter::parse_term(raw)?));
1018        }
1019        if args.has("all") {
1020            return Ok(LifecycleQuery::All);
1021        }
1022        if override_new && args.has("new") {
1023            return Ok(LifecycleQuery::Filtered(LifecycleFilter::New));
1024        }
1025        if override_fixed && args.has("fixed") {
1026            return Ok(LifecycleQuery::Filtered(LifecycleFilter::Removed));
1027        }
1028        if args.has("changed") {
1029            return Ok(LifecycleQuery::Filtered(LifecycleFilter::Changed));
1030        }
1031        Ok(LifecycleQuery::Default)
1032    }
1033
1034    pub fn matches(&self, lifecycle: Lifecycle) -> bool {
1035        match self {
1036            LifecycleQuery::All => true,
1037            LifecycleQuery::Filtered(filter) => filter.matches(lifecycle),
1038            LifecycleQuery::Default => lifecycle != Lifecycle::Removed,
1039        }
1040    }
1041
1042    pub fn label(&self) -> Option<&'static str> {
1043        match self {
1044            LifecycleQuery::All => Some("all"),
1045            LifecycleQuery::Filtered(filter) => match filter {
1046                LifecycleFilter::New => Some("new"),
1047                LifecycleFilter::Active => Some("active"),
1048                LifecycleFilter::Changed => Some("changed"),
1049                LifecycleFilter::Removed => Some("removed"),
1050            },
1051            LifecycleQuery::Default => None,
1052        }
1053    }
1054}
1055
1056#[cfg(test)]
1057mod lifecycle_tests {
1058    use super::*;
1059
1060    #[test]
1061    fn lifecycle_terms_alias() {
1062        assert_eq!(
1063            LifecycleFilter::parse_term("active").unwrap(),
1064            LifecycleFilter::Active
1065        );
1066        assert_eq!(
1067            LifecycleFilter::parse_term("unchanged").unwrap(),
1068            LifecycleFilter::Active
1069        );
1070        assert_eq!(
1071            LifecycleFilter::parse_term("fixed").unwrap(),
1072            LifecycleFilter::Removed
1073        );
1074        assert!(LifecycleFilter::parse_term("bogus").is_err());
1075    }
1076
1077    #[test]
1078    fn lifecycle_matches() {
1079        assert!(LifecycleFilter::New.matches(Lifecycle::New));
1080        assert!(!LifecycleFilter::New.matches(Lifecycle::Active));
1081        assert!(LifecycleFilter::Active.matches(Lifecycle::Active));
1082        assert!(LifecycleFilter::Changed.matches(Lifecycle::Changed));
1083        assert!(LifecycleFilter::Removed.matches(Lifecycle::Removed));
1084    }
1085
1086    #[test]
1087    fn query_default_hides_removed() {
1088        let none = Args::parse(&[]).unwrap();
1089        let query = LifecycleQuery::parse(&none, true, false).unwrap();
1090        assert_eq!(query, LifecycleQuery::Default);
1091        assert!(query.matches(Lifecycle::New));
1092        assert!(query.matches(Lifecycle::Active));
1093        assert!(query.matches(Lifecycle::Changed));
1094        assert!(!query.matches(Lifecycle::Removed));
1095    }
1096
1097    #[test]
1098    fn all_flag_includes_removed() {
1099        let all = Args::parse(&["--all".to_string()]).unwrap();
1100        let query = LifecycleQuery::parse(&all, true, false).unwrap();
1101        assert_eq!(query, LifecycleQuery::All);
1102        assert!(query.matches(Lifecycle::Removed));
1103    }
1104
1105    #[test]
1106    fn lifecycle_and_status_are_interchangeable() {
1107        let by_lifecycle = Args::parse(&["--lifecycle".to_string(), "new".to_string()]).unwrap();
1108        let by_status = Args::parse(&["--status".to_string(), "new".to_string()]).unwrap();
1109        assert_eq!(
1110            LifecycleQuery::parse(&by_lifecycle, true, false).unwrap(),
1111            LifecycleQuery::Filtered(LifecycleFilter::New)
1112        );
1113        assert_eq!(
1114            LifecycleQuery::parse(&by_status, true, false).unwrap(),
1115            LifecycleQuery::Filtered(LifecycleFilter::New)
1116        );
1117        assert!(LifecycleQuery::parse(&by_lifecycle, true, false)
1118            .unwrap()
1119            .matches(Lifecycle::New));
1120    }
1121
1122    #[test]
1123    fn singleton_flags_map() {
1124        let new = Args::parse(&["--new".to_string()]).unwrap();
1125        assert_eq!(
1126            LifecycleQuery::parse(&new, true, false).unwrap(),
1127            LifecycleQuery::Filtered(LifecycleFilter::New)
1128        );
1129        let changed = Args::parse(&["--changed".to_string()]).unwrap();
1130        assert_eq!(
1131            LifecycleQuery::parse(&changed, true, false).unwrap(),
1132            LifecycleQuery::Filtered(LifecycleFilter::Changed)
1133        );
1134        let fixed = Args::parse(&["--fixed".to_string()]).unwrap();
1135        assert_eq!(
1136            LifecycleQuery::parse(&fixed, false, true).unwrap(),
1137            LifecycleQuery::Filtered(LifecycleFilter::Removed)
1138        );
1139    }
1140
1141    #[test]
1142    fn legacy_new_flag_is_ignored_when_unscoped() {
1143        let new = Args::parse(&["--new".to_string()]).unwrap();
1144        assert_eq!(
1145            LifecycleQuery::parse(&new, false, false).unwrap(),
1146            LifecycleQuery::Default
1147        );
1148    }
1149}