Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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}"
    );
}