use std::collections::BTreeMap;
use std::path::Path;
use serde::Deserialize;
use super::promtext::{CORRELATION_METRIC, DETECTION_METRIC, parse_exposition};
use crate::commands::reports::{BacktestReport, CoverageReport};
#[derive(Debug)]
pub(crate) enum InputError {
Unreadable(String),
Malformed(String),
}
impl std::fmt::Display for InputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InputError::Unreadable(m) | InputError::Malformed(m) => f.write_str(m),
}
}
}
pub(crate) fn load_backtest(path: &Path) -> Result<BacktestReport, InputError> {
let raw = read_file(path)?;
serde_json::from_str(&raw).map_err(|e| {
InputError::Malformed(format!(
"could not parse backtest report {} (is it a `rule backtest --report` JSON document \
from a compatible rsigma version?): {e}",
path.display()
))
})
}
pub(crate) fn load_coverage(path: &Path) -> Result<CoverageReport, InputError> {
let raw = read_file(path)?;
serde_json::from_str(&raw).map_err(|e| {
InputError::Malformed(format!(
"could not parse coverage report {} (is it a `rule coverage --output-format json` \
document from a compatible rsigma version?): {e}",
path.display()
))
})
}
fn read_file(path: &Path) -> Result<String, InputError> {
std::fs::read_to_string(path)
.map_err(|e| InputError::Unreadable(format!("could not read {}: {e}", path.display())))
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TriageFeed {
#[serde(default)]
pub(crate) rules: Vec<TriageEntry>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TriageEntry {
pub(crate) rule_id: String,
#[serde(default)]
pub(crate) true_positives: Option<u64>,
#[serde(default)]
pub(crate) false_positives: Option<u64>,
#[serde(default)]
pub(crate) fp_ratio: Option<f64>,
#[serde(default)]
pub(crate) mttd_seconds: Option<f64>,
#[serde(default)]
pub(crate) mttr_seconds: Option<f64>,
}
impl TriageEntry {
pub(crate) fn effective_fp_ratio(&self) -> Option<f64> {
if let Some(r) = self.fp_ratio {
return Some(r.clamp(0.0, 1.0));
}
match (self.true_positives, self.false_positives) {
(Some(tp), Some(fp)) if tp + fp > 0 => Some(fp as f64 / (tp + fp) as f64),
_ => None,
}
}
}
pub(crate) type TriageIndex = BTreeMap<String, TriageEntry>;
pub(crate) fn load_triage(path: &Path) -> Result<TriageIndex, InputError> {
let raw = read_file(path)?;
let feed: TriageFeed = serde_json::from_str(&raw).map_err(|e| {
InputError::Malformed(format!(
"could not parse triage feed {}: {e}",
path.display()
))
})?;
Ok(feed
.rules
.into_iter()
.map(|e| (e.rule_id.clone(), e))
.collect())
}
#[derive(Debug, Clone, Default)]
pub(crate) struct MetricsData {
pub(crate) by_title: BTreeMap<String, u64>,
pub(crate) last_fired: BTreeMap<String, i64>,
}
pub(crate) fn load_metrics(spec: &str, window: Option<&str>) -> Result<MetricsData, InputError> {
match window {
Some(window) => query_range(spec, window),
None => {
let text = read_spec(spec)?;
Ok(MetricsData {
by_title: parse_exposition(&text),
last_fired: BTreeMap::new(),
})
}
}
}
fn read_spec(spec: &str) -> Result<String, InputError> {
if is_url(spec) {
http_get(spec)
} else {
std::fs::read_to_string(spec)
.map_err(|e| InputError::Unreadable(format!("could not read metrics {spec}: {e}")))
}
}
fn is_url(spec: &str) -> bool {
spec.starts_with("http://") || spec.starts_with("https://")
}
fn http_get(url: &str) -> Result<String, InputError> {
match ureq::get(url).call() {
Ok(resp) => resp
.into_body()
.read_to_string()
.map_err(|e| InputError::Unreadable(format!("reading response from {url}: {e}"))),
Err(e) => Err(InputError::Unreadable(format!(
"could not fetch metrics from {url}: {e}"
))),
}
}
fn query_range(base: &str, window: &str) -> Result<MetricsData, InputError> {
if !is_url(base) {
return Err(InputError::Unreadable(format!(
"--metrics-window requires --metrics to be a Prometheus query-API base URL, got {base}"
)));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let lookback = parse_window_secs(window).ok_or_else(|| {
InputError::Unreadable(format!(
"invalid --metrics-window '{window}' (expected e.g. 7d, 24h, 30m)"
))
})?;
let start = now - lookback;
let step = (lookback / 100).max(60);
let mut data = MetricsData::default();
for metric in [DETECTION_METRIC, CORRELATION_METRIC] {
let url = format!(
"{}/api/v1/query_range?query={metric}&start={start}&end={now}&step={step}",
base.trim_end_matches('/')
);
let body = http_get(&url)?;
merge_range_response(&body, &mut data).map_err(InputError::Malformed)?;
}
Ok(data)
}
fn parse_window_secs(window: &str) -> Option<i64> {
let window = window.trim();
let (num, unit) = window.split_at(window.find(|c: char| !c.is_ascii_digit())?);
let n: i64 = num.parse().ok()?;
let mult = match unit {
"s" => 1,
"m" => 60,
"h" => 3_600,
"d" => 86_400,
"w" => 604_800,
_ => return None,
};
Some(n * mult)
}
fn merge_range_response(body: &str, data: &mut MetricsData) -> Result<(), String> {
let parsed: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("parsing query_range response: {e}"))?;
let results = parsed
.get("data")
.and_then(|d| d.get("result"))
.and_then(|r| r.as_array())
.ok_or_else(|| "query_range response missing data.result".to_string())?;
for series in results {
let Some(title) = series
.get("metric")
.and_then(|m| m.get("rule_title"))
.and_then(|t| t.as_str())
else {
continue;
};
let Some(values) = series.get("values").and_then(|v| v.as_array()) else {
continue;
};
let mut prev: Option<f64> = None;
let mut last_value = 0.0f64;
let mut last_fired_ts: Option<i64> = None;
for sample in values {
let Some(arr) = sample.as_array() else {
continue;
};
let ts = arr.first().and_then(|t| t.as_f64());
let val = arr
.get(1)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<f64>().ok());
let (Some(ts), Some(val)) = (ts, val) else {
continue;
};
if prev.is_some_and(|p| val > p) {
last_fired_ts = Some(ts as i64);
}
prev = Some(val);
last_value = val;
}
*data.by_title.entry(title.to_string()).or_insert(0) += last_value.round().max(0.0) as u64;
if let Some(ts) = last_fired_ts {
data.last_fired.insert(title.to_string(), ts);
}
}
Ok(())
}
pub(crate) fn unix_to_rfc3339(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triage_effective_ratio_prefers_explicit_then_counts() {
let explicit = TriageEntry {
rule_id: "r".into(),
true_positives: Some(1),
false_positives: Some(1),
fp_ratio: Some(0.9),
mttd_seconds: None,
mttr_seconds: None,
};
assert_eq!(explicit.effective_fp_ratio(), Some(0.9));
let derived = TriageEntry {
rule_id: "r".into(),
true_positives: Some(8),
false_positives: Some(2),
fp_ratio: None,
mttd_seconds: None,
mttr_seconds: None,
};
assert_eq!(derived.effective_fp_ratio(), Some(0.2));
let none = TriageEntry {
rule_id: "r".into(),
true_positives: None,
false_positives: None,
fp_ratio: None,
mttd_seconds: None,
mttr_seconds: None,
};
assert_eq!(none.effective_fp_ratio(), None);
}
#[test]
fn window_parse_units() {
assert_eq!(parse_window_secs("7d"), Some(604_800));
assert_eq!(parse_window_secs("24h"), Some(86_400));
assert_eq!(parse_window_secs("30m"), Some(1_800));
assert_eq!(parse_window_secs("90s"), Some(90));
assert_eq!(parse_window_secs("bogus"), None);
assert_eq!(parse_window_secs("10y"), None);
}
#[test]
fn rfc3339_epoch_and_known_date() {
assert_eq!(unix_to_rfc3339(0), "1970-01-01T00:00:00Z");
assert_eq!(unix_to_rfc3339(1_609_459_200), "2021-01-01T00:00:00Z");
}
#[test]
fn merge_range_extracts_value_and_last_fired() {
let body = r#"{
"status": "success",
"data": {
"resultType": "matrix",
"result": [
{
"metric": {"rule_title": "Whoami", "level": "low"},
"values": [[1609459200, "2"], [1609459260, "2"], [1609459320, "5"]]
}
]
}
}"#;
let mut data = MetricsData::default();
merge_range_response(body, &mut data).unwrap();
assert_eq!(data.by_title.get("Whoami"), Some(&5));
assert_eq!(data.last_fired.get("Whoami"), Some(&1_609_459_320));
assert_eq!(unix_to_rfc3339(1_609_459_320), "2021-01-01T00:02:00Z");
}
}