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