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
20const BOOLEAN_FLAGS: &[&str] = &[
25 "verbose", "json", "new", "changed", "fixed", "help", "dry-run", "yes", "tui",
26];
27const VALUE_FLAGS: &[&str] = &[
28 "target",
29 "since",
30 "port",
31 "product",
32 "asset",
33 "severity",
34 "cve",
35 "backend",
36 "pocketbase-url",
37 "sqlite-path",
38 "module",
39 "payload",
40 "workspace",
41 "job-timeout",
42];
43
44#[derive(Debug, Default, Clone)]
46pub struct Args {
47 flags: HashSet<String>,
48 values: HashMap<String, String>,
49 positionals: Vec<String>,
50}
51
52impl Args {
53 pub fn parse(args: &[String]) -> Result<Self> {
54 let mut flags = HashSet::new();
55 let mut values = HashMap::new();
56 let mut positionals = Vec::new();
57
58 let mut i = 0usize;
59 while i < args.len() {
60 let arg = &args[i];
61 if let Some(rest) = arg.strip_prefix("--") {
62 if let Some((key, value)) = rest.split_once('=') {
63 if VALUE_FLAGS.contains(&key) {
64 values.insert(key.to_string(), value.to_string());
65 i += 1;
66 continue;
67 }
68 return Err(AppError::Message(format!(
69 "unknown option: --{} (use --{} VALUE)",
70 key, key
71 )));
72 }
73 if VALUE_FLAGS.contains(&rest) {
74 let value = args.get(i + 1).ok_or_else(|| {
75 AppError::Message(format!("option --{} requires a value", rest))
76 })?;
77 values.insert(rest.to_string(), value.clone());
78 i += 2;
79 continue;
80 }
81 if BOOLEAN_FLAGS.contains(&rest) {
82 flags.insert(rest.to_string());
83 i += 1;
84 continue;
85 }
86 return Err(AppError::Message(format!("unknown option: --{}", rest)));
87 } else if arg.starts_with('-') && arg.len() > 1 {
88 let short = &arg[1..];
89 match short {
90 "v" => {
91 flags.insert("verbose".to_string());
92 }
93 "h" => {
94 flags.insert("help".to_string());
95 }
96 _ => {
97 return Err(AppError::Message(format!("unknown option: -{}", short)));
98 }
99 }
100 i += 1;
101 continue;
102 }
103 positionals.push(arg.clone());
104 i += 1;
105 }
106
107 Ok(Self {
108 flags,
109 values,
110 positionals,
111 })
112 }
113
114 pub fn has(&self, name: &str) -> bool {
115 self.flags.contains(name)
116 }
117
118 pub fn get(&self, name: &str) -> Option<&str> {
119 self.values.get(name).map(|s| s.as_str())
120 }
121
122 pub fn positionals(&self) -> &[String] {
123 &self.positionals
124 }
125}
126
127pub fn color_enabled() -> bool {
132 if std::env::var_os("NO_COLOR").is_some() {
133 return false;
134 }
135 io::stdout().is_terminal()
136}
137
138fn paint(text: &str, code: &str) -> String {
139 if color_enabled() {
140 format!("\x1b[{}m{}\x1b[0m", code, text)
141 } else {
142 text.to_string()
143 }
144}
145
146pub fn red(text: &str) -> String {
147 paint(text, "31")
148}
149
150pub fn green(text: &str) -> String {
151 paint(text, "32")
152}
153
154pub fn yellow(text: &str) -> String {
155 paint(text, "33")
156}
157
158pub fn cyan(text: &str) -> String {
159 paint(text, "36")
160}
161
162pub fn dim(text: &str) -> String {
163 paint(text, "2")
164}
165
166pub fn bold(text: &str) -> String {
167 paint(text, "1")
168}
169
170pub fn severity_style(severity: &str) -> &'static str {
171 match severity.to_lowercase().as_str() {
172 "critical" => "1;31",
173 "high" => "31",
174 "medium" => "33",
175 "low" => "34",
176 "info" | "none" => "2",
177 _ => "0",
178 }
179}
180
181pub fn severity_label(severity: &str) -> String {
182 severity.to_uppercase()
183}
184
185pub fn severity_rank(severity: &str) -> u8 {
187 match severity.to_lowercase().as_str() {
188 "critical" => 0,
189 "high" => 1,
190 "medium" => 2,
191 "low" => 3,
192 "info" => 4,
193 "none" => 5,
194 _ => 9,
195 }
196}
197
198pub struct Cell {
203 pub text: String,
204 pub style: Option<&'static str>,
205}
206
207impl Cell {
208 pub fn plain(text: String) -> Self {
209 Self { text, style: None }
210 }
211
212 pub fn styled(text: String, style: &'static str) -> Self {
213 Self {
214 text,
215 style: Some(style),
216 }
217 }
218}
219
220pub struct Table {
221 headers: Vec<String>,
222 rows: Vec<Vec<Cell>>,
223 max_width: usize,
224}
225
226impl Table {
227 pub fn new(headers: Vec<String>) -> Self {
228 Self {
229 headers,
230 rows: Vec::new(),
231 max_width: 60,
232 }
233 }
234
235 pub fn add_row(&mut self, cells: Vec<Cell>) {
236 self.rows.push(cells);
237 }
238
239 pub fn render(&self) -> String {
240 let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
241 for row in &self.rows {
242 for (i, cell) in row.iter().enumerate() {
243 if i < widths.len() {
244 widths[i] = widths[i].max(cell.text.chars().count());
245 }
246 }
247 }
248 for w in widths.iter_mut() {
249 *w = (*w).min(self.max_width);
250 }
251
252 let mut out = String::new();
253 let header_cells: Vec<String> = self
254 .headers
255 .iter()
256 .enumerate()
257 .map(|(i, h)| pad(&truncate(h, widths[i]), widths[i]))
258 .collect();
259 out.push_str(&header_cells.join(" "));
260 out.push('\n');
261
262 out.push_str(
263 &widths
264 .iter()
265 .map(|w| "-".repeat(*w))
266 .collect::<Vec<_>>()
267 .join(" "),
268 );
269 out.push('\n');
270
271 for row in &self.rows {
272 let mut line = String::new();
273 for (i, cell) in row.iter().enumerate() {
274 if i > 0 {
275 line.push_str(" ");
276 }
277 let width = widths.get(i).copied().unwrap_or(self.max_width);
278 let padded = pad(&truncate(&cell.text, width), width);
279 line.push_str(&match cell.style {
280 Some(code) => paint(&padded, code),
281 None => padded,
282 });
283 }
284 out.push_str(&line);
285 out.push('\n');
286 }
287 out
288 }
289}
290
291fn pad(text: &str, width: usize) -> String {
292 let len = text.chars().count();
293 if len >= width {
294 return text.to_string();
295 }
296 let mut out = text.to_string();
297 out.extend(std::iter::repeat_n(' ', width - len));
298 out
299}
300
301pub fn truncate(value: &str, width: usize) -> String {
302 let count = value.chars().count();
303 if count <= width {
304 return value.to_string();
305 }
306 if width <= 1 {
307 return "…".to_string();
308 }
309 let mut out: String = value.chars().take(width - 1).collect();
310 out.push('…');
311 out
312}
313
314fn parse_date_only(s: &str) -> Option<OffsetDateTime> {
319 let mut parts = s.split('-');
320 let year: i32 = parts.next()?.parse().ok()?;
321 let month: u8 = parts.next()?.parse().ok()?;
322 let day: u8 = parts.next()?.parse().ok()?;
323 if parts.next().is_some() {
324 return None;
325 }
326 let date =
327 time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
328 Some(date.midnight().assume_utc())
329}
330
331fn parse_flexible(s: &str) -> Option<OffsetDateTime> {
335 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
336 let re = RE.get_or_init(|| {
337 regex::Regex::new(
338 r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}(?::\d{2})?))?$",
339 )
340 .expect("valid timestamp regex")
341 });
342
343 let caps = re.captures(s.trim())?;
344 let year: i32 = caps.get(1)?.as_str().parse().ok()?;
345 let month: u8 = caps.get(2)?.as_str().parse().ok()?;
346 let day: u8 = caps.get(3)?.as_str().parse().ok()?;
347 let hour: u8 = caps.get(4)?.as_str().parse().ok()?;
348 let minute: u8 = caps.get(5)?.as_str().parse().ok()?;
349 let second: u8 = caps
350 .get(6)
351 .map(|m| m.as_str().parse().ok())
352 .unwrap_or(Some(0))?;
353
354 let date =
355 time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?;
356 let time = time::Time::from_hms(hour, minute, second).ok()?;
357
358 match caps.get(7).map(|m| m.as_str()) {
359 None | Some("Z") => Some(date.with_time(time).assume_utc()),
360 Some(offset) => {
361 let sign: i8 = if offset.starts_with('-') { -1 } else { 1 };
362 let digits = offset.trim_start_matches(['+', '-']);
363 let mut parts = digits.split(':');
364 let oh: i8 = parts.next()?.parse().ok()?;
365 let om: i8 = parts.next()?.parse().ok()?;
366 let utc_offset = time::UtcOffset::from_hms(sign * oh, sign * om, 0).ok()?;
367 Some(date.with_time(time).assume_offset(utc_offset))
368 }
369 }
370}
371
372pub fn parse_ts(value: &str) -> Option<OffsetDateTime> {
375 let trimmed = value.trim();
376 if let Ok(dt) = OffsetDateTime::parse(trimmed, &time::format_description::well_known::Rfc3339) {
377 return Some(dt);
378 }
379 let normalised = trimmed.replace(" UTC", "Z").replace(' ', "T");
380 if let Ok(dt) =
381 OffsetDateTime::parse(&normalised, &time::format_description::well_known::Rfc3339)
382 {
383 return Some(dt);
384 }
385 if let Some(dt) = parse_flexible(trimmed) {
386 return Some(dt);
387 }
388 if let Some(dt) = parse_date_only(trimmed) {
389 return Some(dt);
390 }
391 if let Ok(epoch) = trimmed.parse::<i64>() {
392 if let Ok(dt) = OffsetDateTime::from_unix_timestamp(epoch) {
393 return Some(dt);
394 }
395 }
396 None
397}
398
399pub fn fmt_ts(value: &str) -> String {
401 if value.is_empty() {
402 return "-".to_string();
403 }
404 match parse_ts(value) {
405 Some(dt) => {
406 let utc = dt.to_offset(time::UtcOffset::UTC);
407 format!(
408 "{:04}-{:02}-{:02} {:02}:{:02}",
409 utc.year(),
410 u8::from(utc.month()),
411 utc.day(),
412 utc.hour(),
413 utc.minute()
414 )
415 }
416 None => truncate(value, 16).to_string(),
417 }
418}
419
420pub fn ts_since(ts: &str, since: OffsetDateTime) -> bool {
422 match parse_ts(ts) {
423 Some(dt) => dt >= since,
424 None => false,
425 }
426}
427
428pub fn open_backend(config_mgr: &ConfigManager, args: &Args) -> Result<Box<dyn Storage>> {
435 crate::db::pocketbase::set_verbose(args.has("verbose"));
436 let db = config_mgr.get_database_config().clone();
437 if !db.configured {
438 return Err(AppError::Message(
439 "no database configured; run 'ploit-malper db_setup' first".to_string(),
440 ));
441 }
442 let backend = args.get("backend").unwrap_or(db.backend.as_str());
443 let mut storage = crate::db::open_storage_with(
444 &db,
445 backend,
446 args.get("pocketbase-url"),
447 args.get("sqlite-path"),
448 )?;
449 if !storage.ping()? {
450 return Err(AppError::Message(format!(
451 "{} backend is not reachable",
452 storage.kind()
453 )));
454 }
455 Ok(storage)
456}
457
458pub struct IdMaps {
465 pub assets: HashMap<String, Asset>,
466 pub services: HashMap<String, Service>,
467 pub findings: HashMap<String, Finding>,
468}
469
470impl IdMaps {
471 pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
472 let assets = storage
473 .list_assets()?
474 .into_iter()
475 .map(|a| (a.stable_id.clone(), a))
476 .collect();
477 let services = storage
478 .list_all_services()?
479 .into_iter()
480 .map(|s| (s.stable_id.clone(), s))
481 .collect();
482 let findings = storage
483 .list_all_findings()?
484 .into_iter()
485 .map(|f| (f.stable_id.clone(), f))
486 .collect();
487 Ok(Self {
488 assets,
489 services,
490 findings,
491 })
492 }
493
494 pub fn asset_name(&self, id: &str) -> String {
495 self.assets
496 .get(id)
497 .map(|a| a.name.clone())
498 .unwrap_or_else(|| id.to_string())
499 }
500
501 pub fn service_label(&self, id: &str) -> String {
502 match self.services.get(id) {
503 Some(s) => format!(
504 "{}/{}",
505 s.port,
506 if s.protocol.is_empty() {
507 "tcp"
508 } else {
509 &s.protocol
510 }
511 ),
512 None => id.to_string(),
513 }
514 }
515
516 pub fn service_summary(&self, asset_id: &str) -> String {
517 let mut services: Vec<&Service> = self
518 .services
519 .values()
520 .filter(|s| s.asset_id == asset_id && s.status != "removed")
521 .collect();
522 services.sort_by_key(|s| s.port);
523 if services.is_empty() {
524 return "-".to_string();
525 }
526 format!(
527 "{} open ({})",
528 services.len(),
529 services
530 .iter()
531 .map(|s| s.port.to_string())
532 .collect::<Vec<_>>()
533 .join(",")
534 )
535 }
536
537 pub fn finding_title(&self, id: &str) -> String {
538 self.findings
539 .get(id)
540 .map(|f| f.title.clone())
541 .unwrap_or_else(|| id.to_string())
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum ChangeState {
551 New,
552 Changed,
553 Removed,
554 Unchanged,
555}
556
557impl ChangeState {
558 pub fn label(&self) -> &'static str {
559 match self {
560 ChangeState::New => "new",
561 ChangeState::Changed => "changed",
562 ChangeState::Removed => "removed",
563 ChangeState::Unchanged => "active",
564 }
565 }
566
567 pub fn paint(&self, text: &str) -> String {
568 match self {
569 ChangeState::New => green(text),
570 ChangeState::Changed => yellow(text),
571 ChangeState::Removed => red(text),
572 ChangeState::Unchanged => dim(text),
573 }
574 }
575}
576
577pub fn classify_kind(kind: &str) -> ChangeState {
578 if kind.ends_with("_discovered") {
579 ChangeState::New
580 } else if kind.ends_with("_removed") {
581 ChangeState::Removed
582 } else if kind.contains("changed") {
583 ChangeState::Changed
584 } else {
585 ChangeState::Unchanged
586 }
587}
588
589pub struct ObsIndex {
591 by_subject: HashMap<String, Vec<Observation>>,
592 latest_run: String,
593}
594
595impl ObsIndex {
596 pub fn build(all: &[Observation]) -> Self {
597 let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
598 for obs in all {
599 by_subject
600 .entry(obs.subject_id.clone())
601 .or_default()
602 .push(obs.clone());
603 }
604 for values in by_subject.values_mut() {
605 values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
606 }
607 let latest_run = all
608 .iter()
609 .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
610 .map(|o| o.run_id.clone())
611 .unwrap_or_default();
612 Self {
613 by_subject,
614 latest_run,
615 }
616 }
617
618 pub fn observations_for(&self, id: &str) -> &[Observation] {
619 self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
620 }
621
622 pub fn state(&self, id: &str, status: &str) -> ChangeState {
626 if status == crate::db::models::ASSET_REMOVED {
627 return ChangeState::Removed;
628 }
629 let last = self
630 .observations_for(id)
631 .iter()
632 .rev()
633 .find(|o| o.run_id == self.latest_run);
634 match last {
635 Some(obs) => classify_kind(&obs.kind),
636 None => ChangeState::Unchanged,
637 }
638 }
639
640 pub fn latest_run(&self) -> &str {
641 &self.latest_run
642 }
643}
644
645#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650pub enum SubjectKind {
651 Asset,
652 Service,
653 Finding,
654}
655
656impl SubjectKind {
657 pub fn label(&self) -> &'static str {
658 match self {
659 SubjectKind::Asset => "asset",
660 SubjectKind::Service => "service",
661 SubjectKind::Finding => "finding",
662 }
663 }
664}
665
666pub struct ResolvedSubject {
667 pub kind: SubjectKind,
668 pub id: String,
669 pub label: String,
670}
671
672fn list_some(items: &[String], cap: usize) -> String {
673 items
674 .iter()
675 .take(cap)
676 .map(|s| format!(" - {}", s))
677 .collect::<Vec<_>>()
678 .join("\n")
679}
680
681pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
685 let lower = term.trim().to_lowercase();
686 if lower.is_empty() {
687 return Err(AppError::Message(
688 "history requires an asset, service, or finding id/name".to_string(),
689 ));
690 }
691
692 if let Some(asset) = maps.assets.get(&lower) {
693 return Ok(ResolvedSubject {
694 kind: SubjectKind::Asset,
695 id: asset.stable_id.clone(),
696 label: asset.name.clone(),
697 });
698 }
699 if let Some(service) = maps.services.get(&lower) {
700 return Ok(ResolvedSubject {
701 kind: SubjectKind::Service,
702 id: service.stable_id.clone(),
703 label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
704 });
705 }
706 if let Some(finding) = maps.findings.get(&lower) {
707 return Ok(ResolvedSubject {
708 kind: SubjectKind::Finding,
709 id: finding.stable_id.clone(),
710 label: finding.title.clone(),
711 });
712 }
713
714 let asset_exact: Vec<&Asset> = maps
715 .assets
716 .values()
717 .filter(|a| {
718 a.name.eq_ignore_ascii_case(&lower)
719 || a.ip
720 .as_deref()
721 .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
722 || a.fqdn
723 .as_deref()
724 .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
725 || a.reverse_dns
726 .as_deref()
727 .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
728 })
729 .collect();
730 if asset_exact.len() == 1 {
731 let a = asset_exact[0];
732 return Ok(ResolvedSubject {
733 kind: SubjectKind::Asset,
734 id: a.stable_id.clone(),
735 label: a.name.clone(),
736 });
737 }
738
739 let finding_exact: Vec<&Finding> = maps
740 .findings
741 .values()
742 .filter(|f| f.title.eq_ignore_ascii_case(&lower))
743 .collect();
744 if finding_exact.len() == 1 {
745 let f = finding_exact[0];
746 return Ok(ResolvedSubject {
747 kind: SubjectKind::Finding,
748 id: f.stable_id.clone(),
749 label: f.title.clone(),
750 });
751 }
752
753 if let Some((name_part, port_part)) = lower.rsplit_once(':') {
755 if let Ok(port) = port_part.parse::<u16>() {
756 if let Some(asset) = maps
757 .assets
758 .values()
759 .find(|a| a.name.eq_ignore_ascii_case(name_part))
760 {
761 if let Some(service) = maps
762 .services
763 .values()
764 .find(|s| s.asset_id == asset.stable_id && s.port == port)
765 {
766 return Ok(ResolvedSubject {
767 kind: SubjectKind::Service,
768 id: service.stable_id.clone(),
769 label: format!("{}:{}", asset.name, service.port),
770 });
771 }
772 }
773 }
774 }
775
776 let asset_contains: Vec<String> = maps
778 .assets
779 .values()
780 .filter(|a| {
781 a.name.to_lowercase().contains(&lower)
782 || a.ip
783 .as_deref()
784 .is_some_and(|i| i.to_lowercase().contains(&lower))
785 || a.fqdn
786 .as_deref()
787 .is_some_and(|f| f.to_lowercase().contains(&lower))
788 })
789 .map(|a| a.name.clone())
790 .collect();
791 if asset_contains.len() == 1 {
792 let name = &asset_contains[0];
793 let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
794 return Ok(ResolvedSubject {
795 kind: SubjectKind::Asset,
796 id: asset.stable_id.clone(),
797 label: asset.name.clone(),
798 });
799 }
800
801 let finding_contains: Vec<String> = maps
802 .findings
803 .values()
804 .filter(|f| f.title.to_lowercase().contains(&lower))
805 .map(|f| f.title.clone())
806 .collect();
807 if finding_contains.len() == 1 {
808 let title = &finding_contains[0];
809 let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
810 return Ok(ResolvedSubject {
811 kind: SubjectKind::Finding,
812 id: finding.stable_id.clone(),
813 label: finding.title.clone(),
814 });
815 }
816
817 if !asset_contains.is_empty() || !finding_contains.is_empty() {
818 let mut lines = Vec::new();
819 if !asset_contains.is_empty() {
820 lines.push(format!("Assets matching '{}':", term));
821 lines.push(list_some(&asset_contains, 5));
822 }
823 if !finding_contains.is_empty() {
824 lines.push(format!("Findings matching '{}':", term));
825 lines.push(list_some(&finding_contains, 5));
826 }
827 lines.push("Be more specific, or use a full id.".to_string());
828 return Err(AppError::Message(lines.join("\n")));
829 }
830
831 Err(AppError::Message(format!(
832 "no asset, service, or finding matches '{}'",
833 term
834 )))
835}
836
837pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
842 println!("{}", serde_json::to_string_pretty(value)?);
843 Ok(())
844}
845
846pub fn empty_result(what: &str) {
848 println!("No {} found.", what);
849}
850
851pub fn value_to_short(value: &Value) -> String {
853 match value {
854 Value::Null => "-".to_string(),
855 Value::String(s) => truncate(s, 80).to_string(),
856 other => truncate(&other.to_string(), 120).to_string(),
857 }
858}