use std::collections::{BTreeMap, HashSet};
use rsigma_eval::{RuleIdentity, RuleMetadataLookup};
use rsigma_parser::Level;
use rsigma_parser::ads::{AdsContent, AdsDocument};
use rsigma_runtime::alert_pipeline::IncidentResult;
use rsigma_runtime::risk::RiskEntityView;
use serde::Serialize;
use serde_json::Value;
pub(super) const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BundleFormat {
Json,
Markdown,
}
impl BundleFormat {
pub(super) fn parse(value: &str) -> Option<BundleFormat> {
match value {
"json" => Some(BundleFormat::Json),
"markdown" | "md" => Some(BundleFormat::Markdown),
_ => None,
}
}
pub(super) fn content_type(self) -> &'static str {
match self {
BundleFormat::Json => "application/json",
BundleFormat::Markdown => "text/markdown; charset=utf-8",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct IncidentBundle {
pub schema_version: u32,
pub generated_at: String,
pub sources: BundleSources,
pub incident: IncidentResult,
pub rules: Vec<BundleRule>,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<Vec<BundleRiskEntity>>,
}
#[derive(Debug, Clone, Copy, Serialize)]
pub(super) struct BundleSources {
pub rules: bool,
pub risk: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum RuleResolution {
Unique,
Ambiguous,
Missing,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct BundleRule {
pub key: String,
pub count: u64,
pub resolution: RuleResolution,
pub documents: Vec<BundleRuleDocument>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct BundleRuleDocument {
pub identity: RuleIdentity,
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
pub ads: AdsDocument,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum RiskMatch {
RiskObject,
GroupKey,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct BundleRiskEntity {
pub matched_on: RiskMatch,
#[serde(flatten)]
pub entity: RiskEntityView,
}
pub(super) fn build(
incident: IncidentResult,
metadata: &BTreeMap<String, RuleMetadataLookup>,
risk: Option<&[RiskEntityView]>,
generated_at: String,
) -> IncidentBundle {
let rules = incident
.rule_counts
.iter()
.map(|(key, count)| build_rule(key, *count, metadata.get(key)))
.collect();
let matched_risk = risk.map(|entities| match_risk(&incident, entities));
IncidentBundle {
schema_version: SCHEMA_VERSION,
generated_at,
sources: BundleSources {
rules: true,
risk: risk.is_some(),
},
incident,
rules,
risk: matched_risk,
}
}
fn build_rule(key: &str, count: u64, lookup: Option<&RuleMetadataLookup>) -> BundleRule {
let lookup = lookup.unwrap_or(&RuleMetadataLookup::Missing);
let resolution = match lookup {
RuleMetadataLookup::Missing => RuleResolution::Missing,
RuleMetadataLookup::Unique(_) => RuleResolution::Unique,
RuleMetadataLookup::Ambiguous(_) => RuleResolution::Ambiguous,
};
let documents = lookup
.variants()
.iter()
.map(|meta| BundleRuleDocument {
identity: meta.identity.clone(),
level: meta.level,
tags: meta.tags.clone(),
ads: AdsDocument::from_carriers(meta),
})
.collect();
BundleRule {
key: key.to_string(),
count,
resolution,
documents,
}
}
fn match_risk(incident: &IncidentResult, entities: &[RiskEntityView]) -> Vec<BundleRiskEntity> {
let exact = risk_objects(incident);
let from_grouping = group_key_names(incident);
let mut out = Vec::new();
for entity in entities {
let pair = (entity.entity_type.as_str(), entity.entity_value.as_str());
let matched = if exact.contains(&pair) {
Some(RiskMatch::RiskObject)
} else if from_grouping.contains(&(entity.entity_type.to_lowercase(), pair.1.to_string())) {
Some(RiskMatch::GroupKey)
} else {
None
};
if let Some(matched_on) = matched {
out.push(BundleRiskEntity {
matched_on,
entity: entity.clone(),
});
}
}
out
}
fn risk_objects(incident: &IncidentResult) -> HashSet<(&str, &str)> {
let mut out = HashSet::new();
let Some(results) = &incident.results else {
return out;
};
for result in results {
let Some(objects) = result
.get("enrichments")
.and_then(|e| e.get("risk.objects"))
.and_then(Value::as_array)
else {
continue;
};
for object in objects {
let (Some(object_type), Some(value)) = (
object.get("type").and_then(Value::as_str),
object.get("value").and_then(Value::as_str),
) else {
continue;
};
out.insert((object_type, value));
}
}
out
}
fn group_key_names(incident: &IncidentResult) -> HashSet<(String, String)> {
let mut out = HashSet::new();
let mut push = |selector: &str, value: &Value| {
let Some(value) = value.as_str() else {
return;
};
let Some(segment) = selector.rsplit('.').next() else {
return;
};
out.insert((segment.to_lowercase(), value.to_string()));
};
for (selector, value) in &incident.group_by {
push(selector, value);
}
for (selector, values) in &incident.entities {
for value in values.as_array().into_iter().flatten() {
push(selector, value);
}
}
out
}
pub(super) fn render_markdown(bundle: &IncidentBundle) -> String {
let mut out = String::new();
let incident = &bundle.incident;
out.push_str(&format!("# Incident {}\n\n", incident.incident_id));
out.push_str(&format!("- Generated: {}\n", bundle.generated_at));
out.push_str(&format!("- State: {}\n", incident.state));
if let Some(level) = &incident.max_level {
out.push_str(&format!("- Highest severity: {level}\n"));
}
out.push_str(&format!(
"- Window: {} to {}\n",
timestamp(incident.first_seen),
timestamp(incident.last_seen)
));
out.push_str(&format!(
"- Contributing results: {}\n",
incident.result_count
));
if let Some(mode) = incident.sample_mode {
out.push_str(&format!(
"- Retained samples: {}\n",
serde_json::to_value(mode)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default()
));
}
if incident.bundle_ready == Some(false) {
out.push_str("- Note: still inside `group_wait`, so the incident has not been reported yet and may still change.\n");
}
out.push('\n');
if !incident.group_by.is_empty() {
out.push_str("## Grouping\n\n");
for (selector, value) in &incident.group_by {
out.push_str(&format!("- `{selector}`: {}\n", scalar(value)));
}
out.push('\n');
}
if !incident.entities.is_empty() {
out.push_str("## Entities\n\n");
for (selector, values) in &incident.entities {
let joined: Vec<String> = values
.as_array()
.into_iter()
.flatten()
.map(scalar)
.collect();
out.push_str(&format!("- `{selector}`: {}\n", joined.join(", ")));
}
out.push('\n');
}
out.push_str("## Contributing rules\n\n");
for rule in &bundle.rules {
render_rule(&mut out, rule);
}
match &bundle.risk {
None => {
out.push_str("## Risk\n\nNo risk accumulator is configured.\n\n");
}
Some(entities) if entities.is_empty() => {
out.push_str("## Risk\n\nNo tracked risk entity overlaps this incident.\n\n");
}
Some(entities) => {
out.push_str("## Risk\n\n");
for entry in entities {
let entity = &entry.entity;
out.push_str(&format!(
"### {}: {}\n\n",
entity.entity_type, entity.entity_value
));
out.push_str(&format!("- Score: {}\n", entity.score));
out.push_str(&format!("- Distinct tactics: {}\n", entity.tactic_count));
out.push_str(&format!(
"- Contributing sources: {}\n",
entity.source_count
));
out.push_str(&format!(
"- Window: {} to {}\n",
timestamp(entity.window_start),
timestamp(entity.window_end)
));
out.push_str(&format!(
"- Matched on: {}\n\n",
match entry.matched_on {
RiskMatch::RiskObject => "a contributing result's risk objects",
RiskMatch::GroupKey => "the incident grouping key",
}
));
}
}
}
out
}
fn render_rule(out: &mut String, rule: &BundleRule) {
out.push_str(&format!(
"### `{}` ({} result{})\n\n",
rule.key,
rule.count,
if rule.count == 1 { "" } else { "s" }
));
match rule.resolution {
RuleResolution::Missing => {
out.push_str(
"No loaded rule carries this key. The rule set most likely changed while the \
incident was open.\n\n",
);
return;
}
RuleResolution::Ambiguous => {
out.push_str(&format!(
"{} loaded rules carry this key with differing documentation. All are shown.\n\n",
rule.documents.len()
));
}
RuleResolution::Unique => {}
}
for document in &rule.documents {
out.push_str(&format!("**{}**\n\n", document.identity.title));
if let Some(level) = document.level {
out.push_str(&format!("- Level: {}\n", level.as_str()));
}
if !document.tags.is_empty() {
out.push_str(&format!("- Tags: {}\n", document.tags.join(", ")));
}
out.push('\n');
if document.ads.is_empty() {
out.push_str("This rule carries no ADS documentation.\n\n");
continue;
}
for section in &document.ads.sections {
let Some(content) = §ion.content else {
continue;
};
out.push_str(&format!("*{}*\n\n", heading(section.id)));
match content {
AdsContent::Text(text) => out.push_str(&format!("{text}\n\n")),
AdsContent::List(items) => {
for item in items {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
}
}
}
}
}
fn heading(id: &str) -> String {
let spaced = id.replace('_', " ");
let mut chars = spaced.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => spaced,
}
}
fn scalar(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn timestamp(seconds: i64) -> String {
match chrono::DateTime::from_timestamp(seconds, 0) {
Some(dt) => dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
None => seconds.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rsigma_eval::{RuleBundleMetadata, RuleKind};
use rsigma_runtime::alert_pipeline::{IncidentRef, SampleMode};
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const GENERATED_AT: &str = "2026-07-26T12:00:00Z";
fn documented(id: &str, title: &str) -> RuleMetadataLookup {
RuleMetadataLookup::Unique(Box::new(RuleBundleMetadata {
identity: RuleIdentity {
kind: RuleKind::Detection,
id: Some(id.to_string()),
title: title.to_string(),
},
level: Some(Level::High),
tags: vec!["attack.discovery".to_string(), "attack.t1033".to_string()],
description: Some("Detects whoami execution, a common discovery step.".to_string()),
falsepositives: vec!["Administrators enumerating their own privileges".to_string()],
custom_attributes: Arc::new(
[
(
"rsigma.ads.strategy".to_string(),
json!("Watch process creation for the whoami binary."),
),
(
"rsigma.ads.technical_context".to_string(),
json!("Requires process_creation telemetry with CommandLine."),
),
(
"rsigma.ads.blind_spots".to_string(),
json!(["A renamed binary evades the command-line match."]),
),
(
"rsigma.ads.validation".to_string(),
json!("Run whoami in a lab and confirm the rule fires."),
),
(
"rsigma.ads.priority".to_string(),
json!("High because discovery precedes lateral movement."),
),
(
"rsigma.ads.response".to_string(),
json!([
"Confirm the user and host.",
"Correlate with other discovery."
]),
),
]
.into_iter()
.collect(),
),
}))
}
fn retained_result(rule: &str, objects: Value) -> Value {
json!({
"rule_title": rule,
"rule_id": rule,
"level": "high",
"enrichments": { "risk.score": 60, "risk.objects": objects },
"matched_fields": [{ "field": "User", "value": "alice" }],
})
}
fn incident() -> IncidentResult {
IncidentResult {
incident_id: "f8bcd62a829b1126".to_string(),
state: "open",
trigger: "snapshot",
first_seen: 1_767_225_000,
last_seen: 1_767_225_600,
max_level: Some("high".to_string()),
result_count: 3,
rule_counts: BTreeMap::from([
("rule-whoami".to_string(), 2),
("rule-retired".to_string(), 1),
]),
group_by: serde_json::Map::from_iter([("match.User".to_string(), json!("alice"))]),
entities: serde_json::Map::new(),
refs: None,
results: Some(vec![retained_result(
"rule-whoami",
json!([{ "type": "user", "value": "alice" }]),
)]),
sample_mode: Some(SampleMode::Results),
bundle_ready: Some(true),
}
}
fn metadata() -> BTreeMap<String, RuleMetadataLookup> {
BTreeMap::from([
(
"rule-whoami".to_string(),
documented("rule-whoami", "Whoami execution"),
),
("rule-retired".to_string(), RuleMetadataLookup::Missing),
])
}
fn entity(entity_type: &str, entity_value: &str, score: i64) -> RiskEntityView {
RiskEntityView {
entity_type: entity_type.to_string(),
entity_value: entity_value.to_string(),
score,
tactic_count: 2,
source_count: 2,
result_count: 3,
window_start: 1_767_225_000,
window_end: 1_767_225_600,
last_fired: None,
}
}
fn sample_bundle() -> IncidentBundle {
build(
incident(),
&metadata(),
Some(&[
entity("user", "alice", 120),
entity("host", "alice", 200),
entity("user", "bob", 40),
]),
GENERATED_AT.to_string(),
)
}
#[test]
fn every_contributing_rule_key_gets_an_entry() {
let bundle = sample_bundle();
let keys: Vec<&str> = bundle.rules.iter().map(|r| r.key.as_str()).collect();
assert_eq!(keys, ["rule-retired", "rule-whoami"]);
assert_eq!(bundle.rules[0].resolution, RuleResolution::Missing);
assert!(bundle.rules[0].documents.is_empty());
assert_eq!(bundle.rules[1].resolution, RuleResolution::Unique);
assert_eq!(bundle.rules[1].count, 2);
}
#[test]
fn a_documented_rule_carries_its_full_ads_document() {
let bundle = sample_bundle();
let ads = &bundle.rules[1].documents[0].ads;
assert!(
ads.missing_required().is_empty(),
"{:?}",
ads.missing_required()
);
}
#[test]
fn a_risk_entity_must_match_on_type_as_well_as_value() {
let bundle = sample_bundle();
let matched: Vec<(&str, &str)> = bundle
.risk
.as_ref()
.unwrap()
.iter()
.map(|e| {
(
e.entity.entity_type.as_str(),
e.entity.entity_value.as_str(),
)
})
.collect();
assert_eq!(matched, [("user", "alice")]);
assert_eq!(
bundle.risk.as_ref().unwrap()[0].matched_on,
RiskMatch::RiskObject
);
}
#[test]
fn a_refs_only_incident_falls_back_to_its_grouping_key() {
let mut incident = incident();
incident.results = None;
incident.refs = Some(vec![IncidentRef {
rule: "rule-whoami".to_string(),
level: Some("high".to_string()),
}]);
incident.sample_mode = Some(SampleMode::Refs);
let bundle = build(
incident,
&metadata(),
Some(&[entity("user", "alice", 120), entity("host", "alice", 200)]),
GENERATED_AT.to_string(),
);
let risk = bundle.risk.unwrap();
assert_eq!(risk.len(), 1);
assert_eq!(risk[0].entity.entity_type, "user");
assert_eq!(risk[0].matched_on, RiskMatch::GroupKey);
}
#[test]
fn the_grouping_fallback_ignores_the_case_of_the_entity_type() {
let mut incident = incident();
incident.results = None;
incident.sample_mode = Some(SampleMode::Refs);
let bundle = build(
incident,
&metadata(),
Some(&[entity("User", "alice", 120)]),
GENERATED_AT.to_string(),
);
let risk = bundle.risk.unwrap();
assert_eq!(risk.len(), 1, "a differently-cased type must still join");
assert_eq!(risk[0].matched_on, RiskMatch::GroupKey);
}
#[test]
fn an_unconfigured_risk_layer_is_reported_rather_than_shown_as_empty() {
let bundle = build(incident(), &metadata(), None, GENERATED_AT.to_string());
assert!(bundle.risk.is_none());
assert!(!bundle.sources.risk);
assert!(render_markdown(&bundle).contains("No risk accumulator is configured."));
}
#[test]
fn ambiguous_documentation_is_shown_in_full() {
let mut variants = Vec::new();
for response in ["Page the host on-call.", "Page the cloud on-call."] {
let RuleMetadataLookup::Unique(mut meta) =
documented("rule-whoami", "Whoami execution")
else {
unreachable!()
};
let mut attributes = (*meta.custom_attributes).clone();
attributes.insert("rsigma.ads.response".to_string(), json!(response));
meta.custom_attributes = Arc::new(attributes);
variants.push(*meta);
}
let metadata = BTreeMap::from([(
"rule-whoami".to_string(),
RuleMetadataLookup::from_variants(variants),
)]);
let mut incident = incident();
incident.rule_counts = BTreeMap::from([("rule-whoami".to_string(), 2)]);
let bundle = build(incident, &metadata, None, GENERATED_AT.to_string());
assert_eq!(bundle.rules[0].resolution, RuleResolution::Ambiguous);
assert_eq!(bundle.rules[0].documents.len(), 2);
let markdown = render_markdown(&bundle);
assert!(markdown.contains("Page the host on-call."));
assert!(markdown.contains("Page the cloud on-call."));
}
#[test]
fn a_batching_incident_is_flagged_in_the_report() {
let mut incident = incident();
incident.bundle_ready = Some(false);
let bundle = build(incident, &metadata(), None, GENERATED_AT.to_string());
assert!(render_markdown(&bundle).contains("group_wait"));
}
fn golden_path(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/golden")
.join(name)
}
fn canonical(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
Value::Object(
keys.into_iter()
.map(|key| (key.clone(), canonical(&map[key])))
.collect(),
)
}
Value::Array(items) => Value::Array(items.iter().map(canonical).collect()),
other => other.clone(),
}
}
fn check_golden(name: &str, actual: &str) {
let path = golden_path(name);
if std::env::var_os("RSIGMA_UPDATE_GOLDEN").is_some() {
std::fs::write(&path, actual)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
return;
}
let expected = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()))
.replace("\r\n", "\n");
assert_eq!(actual, expected, "bundle golden drifted for '{name}'");
}
#[test]
fn json_bundle_matches_golden() {
let value = serde_json::to_value(sample_bundle()).unwrap();
let actual = format!(
"{}\n",
serde_json::to_string_pretty(&canonical(&value)).unwrap()
);
check_golden("incident_bundle.json", &actual);
}
#[test]
fn markdown_bundle_matches_golden() {
check_golden("incident_bundle.md", &render_markdown(&sample_bundle()));
}
}