server/cli/tests/audit_export_reaches_the_siem.rs
//! ★ **`holger audit export --config` ships the audit trail to the SIEM** (2c0d2eca): the
//! real binary's verb, run as an operator runs it, against a config whose audit log holds a
//! trail and whose `audit.siem` names three file sinks (JSON lines, RFC 5424 syslog, CEF)
//! and one that is off.
//!
//! * every audit line reaches every enabled sink, in its own format: the JSON lines are the
//! log's own lines (chain fields included), syslog carries the record's `seq`, `ident` and
//! `repo` as structured data, CEF names the person (`suser`) and the repository (`cs1`);
//! * the sink that is not `enabled: true` gets nothing;
//! * the cursor holds: a second run sends nothing, and a new record is the only line the
//! third run sends;
//! * a config with no `audit.siem` block exports nothing and says so.
use std::path::Path;
use std::process::Command;
use server_lib::audit::{AuditAction, AuditEvent, AuditLog as _, FileAuditLog};
/// The package's own binary, provided by cargo to integration tests.
const BIN: &str = env!("CARGO_BIN_EXE_holger");
fn config(path: &Path, audit_dir: &Path, siem: &str) {
let ron = format!(
r#"(
exposed_endpoints: [ ( ron_name: "e", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
storage_endpoints: [],
repositories: [],
audit: ( dir: "{audit}", enabled: true, {siem} ),
)
"#,
audit = audit_dir.display(),
);
std::fs::write(path, ron).expect("write config");
}
fn sinks(out: &Path) -> String {
let f = |n: &str| out.join(n).display().to_string();
format!(
r#"siem: Some((
local_only: true,
sinks: [
(name: "lines", enabled: true, format: JsonLines, target: File("{lines}")),
(name: "syslog", enabled: true, format: Syslog, target: File("{syslog}"), hostname: Some("siem-test")),
(name: "cef", enabled: true, format: Cef, target: File("{cef}")),
(name: "off", format: JsonLines, target: File("{off}")),
],
))"#,
lines = f("lines.jsonl"),
syslog = f("syslog.log"),
cef = f("cef.log"),
off = f("off.jsonl"),
)
}
fn export(cfg: &Path) -> String {
let out = Command::new(BIN)
.args(["audit", "export", "--config"])
.arg(cfg)
.output()
.expect("run holger audit export");
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(out.status.success(), "audit export failed: {said}");
said
}
fn lines(p: &Path) -> Vec<String> {
std::fs::read_to_string(p)
.unwrap_or_default()
.lines()
.map(str::to_owned)
.collect()
}
#[test]
fn audit_export_ships_the_trail_to_every_enabled_siem_sink_from_its_cursor() {
let dir = tempfile::tempdir().unwrap();
let audit = dir.path().join("audit");
let out = dir.path().join("siem-out");
std::fs::create_dir_all(&out).unwrap();
{
let log = FileAuditLog::new(&audit).unwrap();
log.record(AuditEvent::new(
"rita",
AuditAction::Download,
"pypi",
"requests-2.31.0.whl",
"10.0.0.7:5000",
200,
1234,
))
.unwrap();
log.record(AuditEvent::new(
"console",
AuditAction::Upload,
"files",
"hello.txt",
"10.0.0.8:5001",
201,
13,
))
.unwrap();
log.record(AuditEvent::new(
"anonymous",
AuditAction::Download,
"files",
"missing.txt",
"",
404,
0,
))
.unwrap();
}
let trail = lines(&audit.join("audit.jsonl"));
assert!(trail.len() >= 3, "the trail: {trail:?}");
let cfg = dir.path().join("holger.ron");
config(&cfg, &audit, &sinks(&out));
let said = export(&cfg);
let n = trail.len();
for sink in ["lines", "syslog", "cef"] {
assert!(
said.contains(&format!("{sink}: {n} line(s) sent; caught up")),
"{sink}: {said}"
);
}
assert!(
!said.contains("off:"),
"the sink that is off is not run: {said}"
);
assert!(
!out.join("off.jsonl").exists(),
"the sink that is off gets nothing"
);
// JSON lines: the log's own lines, chain fields and all.
assert_eq!(
lines(&out.join("lines.jsonl")),
trail,
"the JSON lines are the audit log's lines"
);
// Syslog: one RFC 5424 message per line, the record's fields as structured data.
let syslog = lines(&out.join("syslog.log"));
assert_eq!(syslog.len(), n, "{syslog:?}");
let rita = syslog
.iter()
.find(|l| l.contains("ident=\"rita\""))
.unwrap_or_else(|| panic!("rita's download: {syslog:?}"));
assert!(
rita.starts_with('<') && rita.contains(">1 ") && rita.contains(" siem-test holger "),
"{rita}"
);
assert!(
rita.contains("[holger@32473 seq=")
&& rita.contains("repo=\"pypi\"")
&& rita.contains("artifact=\"requests-2.31.0.whl\""),
"{rita}"
);
// CEF: the person and the repository named.
let cef = lines(&out.join("cef.log"));
assert_eq!(cef.len(), n, "{cef:?}");
assert!(
cef.iter()
.all(|l| l.starts_with("CEF:0|Holger|holger-server|")),
"{cef:?}"
);
let up = cef
.iter()
.find(|l| l.contains("suser=console"))
.unwrap_or_else(|| panic!("the console's upload: {cef:?}"));
assert!(
up.contains("cs1=files") && up.contains("fname=hello.txt") && up.contains("src=10.0.0.8"),
"{up}"
);
let miss = cef
.iter()
.find(|l| l.contains("fname=missing.txt"))
.unwrap();
assert!(miss.contains("|6|"), "a 404 is a severity-6 event: {miss}");
// The cursor holds: nothing again, then only the new record.
let again = export(&cfg);
for sink in ["lines", "syslog", "cef"] {
assert!(
again.contains(&format!("{sink}: 0 line(s) sent; caught up")),
"{sink}: {again}"
);
}
{
let log = FileAuditLog::new(&audit).unwrap();
log.record(AuditEvent::new(
"rita",
AuditAction::Download,
"pypi",
"urllib3-2.0.0.whl",
"10.0.0.7:5002",
200,
99,
))
.unwrap();
}
let grown = lines(&audit.join("audit.jsonl"));
let more = grown.len() - n;
let third = export(&cfg);
assert!(
third.contains(&format!("lines: {more} line(s) sent; caught up")),
"{third}"
);
assert_eq!(
lines(&out.join("lines.jsonl")),
grown,
"the new record is appended once"
);
assert_eq!(
lines(&out.join("cef.log"))
.iter()
.filter(|l| l.contains("urllib3"))
.count(),
1
);
}
#[test]
fn audit_export_without_a_siem_block_exports_nothing() {
let dir = tempfile::tempdir().unwrap();
let audit = dir.path().join("audit");
FileAuditLog::new(&audit)
.unwrap()
.record(AuditEvent::new(
"rita",
AuditAction::Download,
"pypi",
"x.whl",
"",
200,
1,
))
.unwrap();
let cfg = dir.path().join("holger.ron");
config(&cfg, &audit, "");
let said = export(&cfg);
assert!(
said.contains("no `audit.siem` block") && said.contains("nothing is exported"),
"{said}"
);
}