#![cfg(feature = "daemon")]
mod common;
use common::{DaemonProcess, http_get, http_post, poll_until, temp_file};
use std::time::Duration;
const DOCUMENTED_RULE: &str = r#"
title: Malware sample execution
id: 00000000-0000-0000-0000-0000000000ad
status: test
description: Detects the malware sample marker on a command line.
logsource:
category: test
product: test
detection:
selection:
CommandLine|contains: "malware"
condition: selection
level: high
falsepositives:
- A researcher replaying a sample in a lab
tags:
- attack.execution
custom_attributes:
rsigma.ads.strategy: Match the sample marker on process command lines.
rsigma.ads.technical_context: Requires command-line telemetry.
rsigma.ads.blind_spots:
- An obfuscated command line evades the substring match.
rsigma.ads.validation: Replay the sample and confirm the rule fires.
rsigma.ads.priority: High because execution is mid-kill-chain.
rsigma.ads.response:
- Isolate the host.
"#;
const GROUPING: &str = r#"
group:
by:
- match.CommandLine
group_wait: 0s
resolve_timeout: 1h
"#;
const SLOW_GROUPING: &str = r#"
group:
by:
- match.CommandLine
group_wait: 1h
resolve_timeout: 2h
"#;
const DEDUP_ONLY: &str = r#"
dedup:
fingerprint:
- rule
repeat_interval: 0
resolve_timeout: 1h
"#;
struct Fixture {
daemon: DaemonProcess,
_rule: tempfile::NamedTempFile,
_pipeline: tempfile::NamedTempFile,
_output: tempfile::NamedTempFile,
}
fn daemon_with(alert_pipeline: &str) -> Fixture {
let rule = temp_file(".yml", DOCUMENTED_RULE);
let pipeline = temp_file(".yml", alert_pipeline);
let output = tempfile::NamedTempFile::new().unwrap();
let daemon = DaemonProcess::spawn(&[
"engine",
"daemon",
"-r",
rule.path().to_str().unwrap(),
"--alert-pipeline",
pipeline.path().to_str().unwrap(),
"--input",
"http",
"--api-addr",
"127.0.0.1:0",
"--output",
&format!("file://{}", output.path().to_str().unwrap()),
]);
Fixture {
daemon,
_rule: rule,
_pipeline: pipeline,
_output: output,
}
}
fn open_incident(daemon: &DaemonProcess) -> String {
let body = serde_json::to_string(&serde_json::json!({"CommandLine": "malware x"})).unwrap();
let (status, _) = http_post(&daemon.url("/api/v1/events"), &body);
assert_eq!(status, 200);
poll_until(Duration::from_secs(10), || {
let (status, body) = http_get(&daemon.url("/api/v1/incidents"));
if status != 200 {
return None;
}
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
let incident = v["incidents"].as_array()?.first()?;
if incident["bundle_ready"] != serde_json::json!(true) {
return None;
}
Some(incident["incident_id"].as_str()?.to_string())
})
.expect("no incident opened within 10s")
}
#[test]
fn the_detail_route_returns_one_incident() {
let fixture = daemon_with(GROUPING);
let id = open_incident(&fixture.daemon);
let (status, body) = http_get(&fixture.daemon.url(&format!("/api/v1/incidents/{id}")));
assert_eq!(status, 200, "{body}");
let incident: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(incident["incident_id"], serde_json::json!(id));
assert_eq!(incident["bundle_ready"], serde_json::json!(true));
assert_eq!(incident["sample_mode"], serde_json::json!("refs"));
}
#[test]
fn an_unknown_incident_is_not_found() {
let fixture = daemon_with(GROUPING);
open_incident(&fixture.daemon);
for path in ["/api/v1/incidents/nope", "/api/v1/incidents/nope/bundle"] {
let (status, body) = http_get(&fixture.daemon.url(path));
assert_eq!(status, 404, "{path}: {body}");
let error: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(error["incident_id"], serde_json::json!("nope"));
assert!(error["hint"].as_str().unwrap().contains("resolved"));
}
}
#[test]
fn the_routes_report_grouping_as_unavailable_rather_than_missing() {
let fixture = daemon_with(DEDUP_ONLY);
let body = serde_json::to_string(&serde_json::json!({"CommandLine": "malware x"})).unwrap();
http_post(&fixture.daemon.url("/api/v1/events"), &body);
for path in ["/api/v1/incidents/any", "/api/v1/incidents/any/bundle"] {
let (status, body) = http_get(&fixture.daemon.url(path));
assert_eq!(status, 503, "{path}: {body}");
let error: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
error["error"],
serde_json::json!("incident grouping disabled")
);
}
}
#[test]
fn the_json_bundle_documents_every_contributing_rule() {
let fixture = daemon_with(GROUPING);
let id = open_incident(&fixture.daemon);
let (status, body) = http_get(
&fixture
.daemon
.url(&format!("/api/v1/incidents/{id}/bundle")),
);
assert_eq!(status, 200, "{body}");
let bundle: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(bundle["schema_version"], serde_json::json!(1));
assert_eq!(bundle["incident"]["incident_id"], serde_json::json!(id));
assert!(bundle["generated_at"].as_str().unwrap().ends_with('Z'));
let rules = bundle["rules"].as_array().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0]["resolution"], serde_json::json!("unique"));
assert_eq!(
rules[0]["documents"][0]["identity"]["title"],
serde_json::json!("Malware sample execution")
);
let sections = rules[0]["documents"][0]["ads"]["sections"]
.as_array()
.unwrap();
assert_eq!(sections.len(), 9);
assert!(
sections
.iter()
.all(|s| s["present"] == serde_json::json!(true)),
"an ADS section was lost between the rule file and the bundle: {sections:#?}"
);
assert_eq!(bundle["sources"]["risk"], serde_json::json!(false));
assert!(bundle.get("risk").is_none());
}
#[test]
fn the_markdown_bundle_is_served_as_markdown() {
let fixture = daemon_with(GROUPING);
let id = open_incident(&fixture.daemon);
let url = fixture
.daemon
.url(&format!("/api/v1/incidents/{id}/bundle?format=markdown"));
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let response = agent.get(&url).call().unwrap();
assert_eq!(response.status().as_u16(), 200);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("text/markdown; charset=utf-8")
);
let body = response.into_body().read_to_string().unwrap();
assert!(body.starts_with(&format!("# Incident {id}\n")));
assert!(body.contains("## Contributing rules"));
assert!(body.contains("Isolate the host."));
}
#[test]
fn a_batching_incident_is_readable_but_not_yet_bundleable() {
let fixture = daemon_with(SLOW_GROUPING);
let body = serde_json::to_string(&serde_json::json!({"CommandLine": "malware x"})).unwrap();
assert_eq!(
http_post(&fixture.daemon.url("/api/v1/events"), &body).0,
200
);
let id = poll_until(Duration::from_secs(10), || {
let (status, body) = http_get(&fixture.daemon.url("/api/v1/incidents"));
if status != 200 {
return None;
}
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
Some(
v["incidents"].as_array()?.first()?["incident_id"]
.as_str()?
.to_string(),
)
})
.expect("no incident was created within 10s");
let (status, body) = http_get(&fixture.daemon.url(&format!("/api/v1/incidents/{id}")));
assert_eq!(status, 200, "{body}");
let incident: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(incident["bundle_ready"], serde_json::json!(false));
let (status, body) = http_get(
&fixture
.daemon
.url(&format!("/api/v1/incidents/{id}/bundle")),
);
assert_eq!(status, 409, "{body}");
let error: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(error["hint"].as_str().unwrap().contains("group_wait"));
}
#[test]
fn an_unknown_format_is_rejected() {
let fixture = daemon_with(GROUPING);
let id = open_incident(&fixture.daemon);
let (status, body) = http_get(
&fixture
.daemon
.url(&format!("/api/v1/incidents/{id}/bundle?format=pdf")),
);
assert_eq!(status, 400, "{body}");
let error: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(error["error"].as_str().unwrap().contains("pdf"));
}