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 let db = config_mgr.get_database_config().clone();
435 if !db.configured {
436 return Err(AppError::Message(
437 "no database configured; run 'ploit-malper db_setup' first".to_string(),
438 ));
439 }
440 let backend = args.get("backend").unwrap_or(db.backend.as_str());
441 let mut storage = crate::db::open_storage_with(
442 &db,
443 backend,
444 args.get("pocketbase-url"),
445 args.get("sqlite-path"),
446 )?;
447 if !storage.ping()? {
448 return Err(AppError::Message(format!(
449 "{} backend is not reachable",
450 storage.kind()
451 )));
452 }
453 Ok(storage)
454}
455
456pub struct IdMaps {
463 pub assets: HashMap<String, Asset>,
464 pub services: HashMap<String, Service>,
465 pub findings: HashMap<String, Finding>,
466}
467
468impl IdMaps {
469 pub fn load(storage: &mut Box<dyn Storage>) -> Result<Self> {
470 let assets = storage
471 .list_assets()?
472 .into_iter()
473 .map(|a| (a.stable_id.clone(), a))
474 .collect();
475 let services = storage
476 .list_all_services()?
477 .into_iter()
478 .map(|s| (s.stable_id.clone(), s))
479 .collect();
480 let findings = storage
481 .list_all_findings()?
482 .into_iter()
483 .map(|f| (f.stable_id.clone(), f))
484 .collect();
485 Ok(Self {
486 assets,
487 services,
488 findings,
489 })
490 }
491
492 pub fn asset_name(&self, id: &str) -> String {
493 self.assets
494 .get(id)
495 .map(|a| a.name.clone())
496 .unwrap_or_else(|| id.to_string())
497 }
498
499 pub fn service_label(&self, id: &str) -> String {
500 match self.services.get(id) {
501 Some(s) => format!(
502 "{}/{}",
503 s.port,
504 if s.protocol.is_empty() {
505 "tcp"
506 } else {
507 &s.protocol
508 }
509 ),
510 None => id.to_string(),
511 }
512 }
513
514 pub fn service_summary(&self, asset_id: &str) -> String {
515 let mut services: Vec<&Service> = self
516 .services
517 .values()
518 .filter(|s| s.asset_id == asset_id && s.status != "removed")
519 .collect();
520 services.sort_by_key(|s| s.port);
521 if services.is_empty() {
522 return "-".to_string();
523 }
524 format!(
525 "{} open ({})",
526 services.len(),
527 services
528 .iter()
529 .map(|s| s.port.to_string())
530 .collect::<Vec<_>>()
531 .join(",")
532 )
533 }
534
535 pub fn finding_title(&self, id: &str) -> String {
536 self.findings
537 .get(id)
538 .map(|f| f.title.clone())
539 .unwrap_or_else(|| id.to_string())
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum ChangeState {
549 New,
550 Changed,
551 Removed,
552 Unchanged,
553}
554
555impl ChangeState {
556 pub fn label(&self) -> &'static str {
557 match self {
558 ChangeState::New => "new",
559 ChangeState::Changed => "changed",
560 ChangeState::Removed => "removed",
561 ChangeState::Unchanged => "active",
562 }
563 }
564
565 pub fn paint(&self, text: &str) -> String {
566 match self {
567 ChangeState::New => green(text),
568 ChangeState::Changed => yellow(text),
569 ChangeState::Removed => red(text),
570 ChangeState::Unchanged => dim(text),
571 }
572 }
573}
574
575pub fn classify_kind(kind: &str) -> ChangeState {
576 if kind.ends_with("_discovered") {
577 ChangeState::New
578 } else if kind.ends_with("_removed") {
579 ChangeState::Removed
580 } else if kind.contains("changed") {
581 ChangeState::Changed
582 } else {
583 ChangeState::Unchanged
584 }
585}
586
587pub struct ObsIndex {
589 by_subject: HashMap<String, Vec<Observation>>,
590 latest_run: String,
591}
592
593impl ObsIndex {
594 pub fn build(all: &[Observation]) -> Self {
595 let mut by_subject: HashMap<String, Vec<Observation>> = HashMap::new();
596 for obs in all {
597 by_subject
598 .entry(obs.subject_id.clone())
599 .or_default()
600 .push(obs.clone());
601 }
602 for values in by_subject.values_mut() {
603 values.sort_by(|a, b| a.observed_at.cmp(&b.observed_at));
604 }
605 let latest_run = all
606 .iter()
607 .max_by(|a, b| a.observed_at.cmp(&b.observed_at))
608 .map(|o| o.run_id.clone())
609 .unwrap_or_default();
610 Self {
611 by_subject,
612 latest_run,
613 }
614 }
615
616 pub fn observations_for(&self, id: &str) -> &[Observation] {
617 self.by_subject.get(id).map(|v| v.as_slice()).unwrap_or(&[])
618 }
619
620 pub fn state(&self, id: &str, status: &str) -> ChangeState {
624 if status == crate::db::models::ASSET_REMOVED {
625 return ChangeState::Removed;
626 }
627 let last = self
628 .observations_for(id)
629 .iter()
630 .rev()
631 .find(|o| o.run_id == self.latest_run);
632 match last {
633 Some(obs) => classify_kind(&obs.kind),
634 None => ChangeState::Unchanged,
635 }
636 }
637
638 pub fn latest_run(&self) -> &str {
639 &self.latest_run
640 }
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
648pub enum SubjectKind {
649 Asset,
650 Service,
651 Finding,
652}
653
654impl SubjectKind {
655 pub fn label(&self) -> &'static str {
656 match self {
657 SubjectKind::Asset => "asset",
658 SubjectKind::Service => "service",
659 SubjectKind::Finding => "finding",
660 }
661 }
662}
663
664pub struct ResolvedSubject {
665 pub kind: SubjectKind,
666 pub id: String,
667 pub label: String,
668}
669
670fn list_some(items: &[String], cap: usize) -> String {
671 items
672 .iter()
673 .take(cap)
674 .map(|s| format!(" - {}", s))
675 .collect::<Vec<_>>()
676 .join("\n")
677}
678
679pub fn resolve_subject(maps: &IdMaps, term: &str) -> Result<ResolvedSubject> {
683 let lower = term.trim().to_lowercase();
684 if lower.is_empty() {
685 return Err(AppError::Message(
686 "history requires an asset, service, or finding id/name".to_string(),
687 ));
688 }
689
690 if let Some(asset) = maps.assets.get(&lower) {
691 return Ok(ResolvedSubject {
692 kind: SubjectKind::Asset,
693 id: asset.stable_id.clone(),
694 label: asset.name.clone(),
695 });
696 }
697 if let Some(service) = maps.services.get(&lower) {
698 return Ok(ResolvedSubject {
699 kind: SubjectKind::Service,
700 id: service.stable_id.clone(),
701 label: format!("{}:{}", maps.asset_name(&service.asset_id), service.port),
702 });
703 }
704 if let Some(finding) = maps.findings.get(&lower) {
705 return Ok(ResolvedSubject {
706 kind: SubjectKind::Finding,
707 id: finding.stable_id.clone(),
708 label: finding.title.clone(),
709 });
710 }
711
712 let asset_exact: Vec<&Asset> = maps
713 .assets
714 .values()
715 .filter(|a| {
716 a.name.eq_ignore_ascii_case(&lower)
717 || a.ip
718 .as_deref()
719 .is_some_and(|i| i.eq_ignore_ascii_case(&lower))
720 || a.fqdn
721 .as_deref()
722 .is_some_and(|f| f.eq_ignore_ascii_case(&lower))
723 || a.reverse_dns
724 .as_deref()
725 .is_some_and(|r| r.eq_ignore_ascii_case(&lower))
726 })
727 .collect();
728 if asset_exact.len() == 1 {
729 let a = asset_exact[0];
730 return Ok(ResolvedSubject {
731 kind: SubjectKind::Asset,
732 id: a.stable_id.clone(),
733 label: a.name.clone(),
734 });
735 }
736
737 let finding_exact: Vec<&Finding> = maps
738 .findings
739 .values()
740 .filter(|f| f.title.eq_ignore_ascii_case(&lower))
741 .collect();
742 if finding_exact.len() == 1 {
743 let f = finding_exact[0];
744 return Ok(ResolvedSubject {
745 kind: SubjectKind::Finding,
746 id: f.stable_id.clone(),
747 label: f.title.clone(),
748 });
749 }
750
751 if let Some((name_part, port_part)) = lower.rsplit_once(':') {
753 if let Ok(port) = port_part.parse::<u16>() {
754 if let Some(asset) = maps
755 .assets
756 .values()
757 .find(|a| a.name.eq_ignore_ascii_case(name_part))
758 {
759 if let Some(service) = maps
760 .services
761 .values()
762 .find(|s| s.asset_id == asset.stable_id && s.port == port)
763 {
764 return Ok(ResolvedSubject {
765 kind: SubjectKind::Service,
766 id: service.stable_id.clone(),
767 label: format!("{}:{}", asset.name, service.port),
768 });
769 }
770 }
771 }
772 }
773
774 let asset_contains: Vec<String> = maps
776 .assets
777 .values()
778 .filter(|a| {
779 a.name.to_lowercase().contains(&lower)
780 || a.ip
781 .as_deref()
782 .is_some_and(|i| i.to_lowercase().contains(&lower))
783 || a.fqdn
784 .as_deref()
785 .is_some_and(|f| f.to_lowercase().contains(&lower))
786 })
787 .map(|a| a.name.clone())
788 .collect();
789 if asset_contains.len() == 1 {
790 let name = &asset_contains[0];
791 let asset = maps.assets.values().find(|a| a.name == *name).unwrap();
792 return Ok(ResolvedSubject {
793 kind: SubjectKind::Asset,
794 id: asset.stable_id.clone(),
795 label: asset.name.clone(),
796 });
797 }
798
799 let finding_contains: Vec<String> = maps
800 .findings
801 .values()
802 .filter(|f| f.title.to_lowercase().contains(&lower))
803 .map(|f| f.title.clone())
804 .collect();
805 if finding_contains.len() == 1 {
806 let title = &finding_contains[0];
807 let finding = maps.findings.values().find(|f| f.title == *title).unwrap();
808 return Ok(ResolvedSubject {
809 kind: SubjectKind::Finding,
810 id: finding.stable_id.clone(),
811 label: finding.title.clone(),
812 });
813 }
814
815 if !asset_contains.is_empty() || !finding_contains.is_empty() {
816 let mut lines = Vec::new();
817 if !asset_contains.is_empty() {
818 lines.push(format!("Assets matching '{}':", term));
819 lines.push(list_some(&asset_contains, 5));
820 }
821 if !finding_contains.is_empty() {
822 lines.push(format!("Findings matching '{}':", term));
823 lines.push(list_some(&finding_contains, 5));
824 }
825 lines.push("Be more specific, or use a full id.".to_string());
826 return Err(AppError::Message(lines.join("\n")));
827 }
828
829 Err(AppError::Message(format!(
830 "no asset, service, or finding matches '{}'",
831 term
832 )))
833}
834
835pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
840 println!("{}", serde_json::to_string_pretty(value)?);
841 Ok(())
842}
843
844pub fn empty_result(what: &str) {
846 println!("No {} found.", what);
847}
848
849pub fn value_to_short(value: &Value) -> String {
851 match value {
852 Value::Null => "-".to_string(),
853 Value::String(s) => truncate(s, 80).to_string(),
854 other => truncate(&other.to_string(), 120).to_string(),
855 }
856}