use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, Write as _};
use std::path::Path;
use anyhow::{Context as _, Result, bail};
use openmoat_audit::{Event, ExportedEvent, Summary};
use openmoat_core::Verdict;
use serde::Serialize;
use crate::approvals::OVERLAY_PREFIX;
use crate::cli::{Format, TeamReportArgs};
use crate::exit::Code;
use crate::render;
const TOP: usize = 10;
const ACTION_WIDTH: usize = 60;
#[derive(Serialize)]
struct Source {
file: String,
events: u64,
head: Option<String>,
}
#[derive(Default, Serialize)]
struct Counts {
allowed: u64,
asked: u64,
denied: u64,
}
#[derive(Serialize)]
struct RuleCounts {
rule: String,
asked: u64,
denied: u64,
}
#[derive(Serialize)]
struct ActionCount {
action: String,
count: u64,
}
#[derive(Default, Serialize)]
struct Candidate {
action: String,
rules: BTreeSet<String>,
asks: u64,
approvals: u64,
}
#[derive(Serialize)]
struct TeamReport {
sources: Vec<Source>,
summary: Summary,
by_host: BTreeMap<String, Counts>,
by_rule: Vec<RuleCounts>,
top_asks: Vec<ActionCount>,
top_denies: Vec<ActionCount>,
false_positive_candidates: Vec<Candidate>,
}
pub fn run(args: &TeamReportArgs) -> Result<Code> {
let mut sources = Vec::new();
let mut seen = BTreeSet::new();
let mut events = Vec::new();
for file in &args.files {
let (source, exported) = read_export(file)?;
sources.push(source);
for event in exported {
if seen.insert(event.hash.clone()) {
events.push(event.to_event()?);
}
}
}
let report = TeamReport::of(sources, &events);
match args.format {
Format::Json => render::json(&report)?,
Format::Text => text(&report)?,
}
Ok(Code::Ok)
}
fn read_export(file: &Path) -> Result<(Source, Vec<ExportedEvent>)> {
let name = file.display();
let text = std::fs::read_to_string(file).with_context(|| format!("reading {name}"))?;
let check = openmoat_audit::verify_export(&text, None);
if let Some(broken) = check.broken {
bail!(
"{name}: line {} does not verify ({}); run `moat audit verify` on it",
broken.line,
broken.kind.describe()
);
}
let events = text
.lines()
.filter(|line| !line.trim().is_empty())
.map(serde_json::from_str)
.collect::<Result<_, _>>()
.with_context(|| format!("parsing {name}"))?;
let source = Source {
file: name.to_string(),
events: check.events,
head: check.head,
};
Ok((source, events))
}
impl TeamReport {
fn of(sources: Vec<Source>, events: &[Event]) -> Self {
let since_ms = events.iter().map(|e| e.ts_ms).min().unwrap_or(0);
let mut by_host: BTreeMap<String, Counts> = BTreeMap::new();
let mut rules: BTreeMap<String, (u64, u64)> = BTreeMap::new();
let mut asks: BTreeMap<String, u64> = BTreeMap::new();
let mut denies: BTreeMap<String, u64> = BTreeMap::new();
let mut candidates: BTreeMap<String, Candidate> = BTreeMap::new();
for event in events {
let host = by_host.entry(event.host.clone()).or_default();
let action = render::describe(event);
match event.verdict {
Verdict::Allow => host.allowed += 1,
Verdict::Ask => host.asked += 1,
_ => host.denied += 1,
}
for rule in &event.rules {
let counts = rules.entry(rule.clone()).or_default();
match event.verdict {
Verdict::Allow => {}
Verdict::Ask => counts.0 += 1,
_ => counts.1 += 1,
}
}
let approved = event.rules.iter().any(|r| r.starts_with(OVERLAY_PREFIX));
match event.verdict {
Verdict::Ask => {
*asks.entry(action.clone()).or_default() += 1;
let candidate = candidates.entry(action).or_default();
candidate.asks += 1;
candidate.rules.extend(event.rules.iter().cloned());
}
Verdict::Allow if approved => candidates.entry(action).or_default().approvals += 1,
Verdict::Allow => {}
_ => *denies.entry(action).or_default() += 1,
}
}
let mut by_rule: Vec<RuleCounts> = rules
.into_iter()
.filter(|(_, (asked, denied))| asked + denied > 0)
.map(|(rule, (asked, denied))| RuleCounts {
rule,
asked,
denied,
})
.collect();
by_rule.sort_by_key(|r| std::cmp::Reverse(r.asked + r.denied));
let mut false_positive_candidates: Vec<Candidate> = candidates
.into_iter()
.filter(|(_, c)| c.asks > 0 && c.approvals > 0)
.map(|(action, c)| Candidate { action, ..c })
.collect();
false_positive_candidates.sort_by_key(|c| std::cmp::Reverse(c.asks + c.approvals));
false_positive_candidates.truncate(TOP);
Self {
sources,
summary: Summary::of(since_ms, events),
by_host,
by_rule,
top_asks: ranked(asks),
top_denies: ranked(denies),
false_positive_candidates,
}
}
}
fn ranked(counts: BTreeMap<String, u64>) -> Vec<ActionCount> {
let mut ranked: Vec<ActionCount> = counts
.into_iter()
.map(|(action, count)| ActionCount { action, count })
.collect();
ranked.sort_by_key(|a| std::cmp::Reverse(a.count));
ranked.truncate(TOP);
ranked
}
fn text(report: &TeamReport) -> Result<()> {
render::report(&report.summary, "the first exported event")?;
let mut out = io::stdout().lock();
writeln!(out, "exports (verified):")?;
for source in &report.sources {
let head = source.head.as_deref().unwrap_or("-");
writeln!(
out,
" {} {} events head {head}",
source.file, source.events
)?;
}
writeln!(out, "per host (allowed · asked · denied):")?;
for (host, c) in &report.by_host {
let row = format!(" {host:<12} {} · {} · {}", c.allowed, c.asked, c.denied);
render::line(&mut out, &row)?;
}
if !report.by_rule.is_empty() {
writeln!(out, "per rule (asked · denied):")?;
for r in &report.by_rule {
let row = format!(" {:<20} {} · {}", r.rule, r.asked, r.denied);
render::line(&mut out, &row)?;
}
}
for (title, list) in [
("top asks", &report.top_asks),
("top denies", &report.top_denies),
] {
if !list.is_empty() {
writeln!(out, "{title}:")?;
for a in list {
let action = render::truncate(&a.action, ACTION_WIDTH);
render::line(&mut out, &format!(" {:>5} {action}", a.count))?;
}
}
}
if !report.false_positive_candidates.is_empty() {
writeln!(
out,
"false-positive candidates (asked, and approved with `moat allow`):"
)?;
for c in &report.false_positive_candidates {
let rules: Vec<&str> = c.rules.iter().map(String::as_str).collect();
let row = format!(
" {} asks, {} approvals {} [{}]",
c.asks,
c.approvals,
render::truncate(&c.action, ACTION_WIDTH),
rules.join(", ")
);
render::line(&mut out, &row)?;
}
}
Ok(())
}