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