1use std::path::{Path, PathBuf};
23
24use chrono::Utc;
25use csaf_models::audit_log::{self, AuditLogEntry};
26use csaf_models::db::DbPool;
27use csaf_models::settings::Settings;
28
29use crate::error::{CsafError, Result};
30use crate::sidecar;
31
32const MAX_AUDIT_ROWS: usize = 10_000_000;
38
39const SARIF_DRIVER_NAME: &str = "csaf-crud";
41
42const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[allow(clippy::struct_excessive_bools)]
51pub struct AuditExportOptions {
52 pub markdown: bool,
54 pub csv: bool,
56 pub json: bool,
58 pub sarif: bool,
60}
61
62impl Default for AuditExportOptions {
63 fn default() -> Self {
64 Self {
65 markdown: true,
66 csv: true,
67 json: true,
68 sarif: true,
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct AuditExportResult {
76 pub timestamp: String,
78 pub rows: usize,
80 pub written: Vec<PathBuf>,
83 pub sidecars: Vec<PathBuf>,
85}
86
87pub fn export_audit_log(
100 pool: &DbPool,
101 out_dir: &Path,
102 settings: &Settings,
103 opts: &AuditExportOptions,
104) -> Result<AuditExportResult> {
105 std::fs::create_dir_all(out_dir)?;
106
107 let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ").to_string();
110
111 let entries: Vec<AuditLogEntry> =
112 pool.with_conn(|conn| audit_log::list(conn, None, MAX_AUDIT_ROWS, 0))?;
113
114 let rows = entries.len();
115 let mut written: Vec<PathBuf> = Vec::new();
116 let mut sidecars: Vec<PathBuf> = Vec::new();
117
118 if opts.markdown {
119 let path = out_dir.join(format!("audit-{timestamp}.md"));
120 let body = to_markdown(&entries);
121 std::fs::write(&path, &body)?;
122 record_sidecars(&path, body.as_bytes(), settings, &mut sidecars)?;
123 written.push(path);
124 }
125
126 if opts.csv {
127 let path = out_dir.join(format!("audit-{timestamp}.csv"));
128 let body = to_csv(&entries);
129 std::fs::write(&path, &body)?;
130 record_sidecars(&path, body.as_bytes(), settings, &mut sidecars)?;
131 written.push(path);
132 }
133
134 if opts.json {
135 let path = out_dir.join(format!("audit-{timestamp}.json"));
136 let body = serde_json::to_vec_pretty(&entries)?;
137 std::fs::write(&path, &body)?;
138 record_sidecars(&path, &body, settings, &mut sidecars)?;
139 written.push(path);
140 }
141
142 if opts.sarif {
143 let path = out_dir.join(format!("audit-{timestamp}.sarif"));
144 let sarif_value = to_sarif(&entries);
145 validate_sarif(&sarif_value)?;
149 let body = serde_json::to_vec_pretty(&sarif_value)?;
150 std::fs::write(&path, &body)?;
151 record_sidecars(&path, &body, settings, &mut sidecars)?;
152 written.push(path);
153 }
154
155 Ok(AuditExportResult {
156 timestamp,
157 rows,
158 written,
159 sidecars,
160 })
161}
162
163fn record_sidecars(
166 payload_path: &Path,
167 bytes: &[u8],
168 settings: &Settings,
169 sidecars: &mut Vec<PathBuf>,
170) -> Result<()> {
171 let (s256, s512, s3) = sidecar::write_sidecar_files_for(
172 payload_path,
173 bytes,
174 settings.sidecar_sha256,
175 settings.sidecar_sha512,
176 settings.sidecar_sha3_512,
177 )?;
178 if let Some(p) = s256 {
179 sidecars.push(p);
180 }
181 if let Some(p) = s512 {
182 sidecars.push(p);
183 }
184 if let Some(p) = s3 {
185 sidecars.push(p);
186 }
187 Ok(())
188}
189
190fn to_markdown(entries: &[AuditLogEntry]) -> String {
192 use std::fmt::Write as _;
193
194 let mut out = String::with_capacity(256 + entries.len() * 120);
195 out.push_str("# Audit Log Export\n\n");
196 let _ = writeln!(
197 out,
198 "Generated: `{}`\n",
199 Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
200 );
201 let _ = writeln!(out, "Total rows: **{}**\n", entries.len());
202 out.push_str("| id | timestamp | action | tracking_id | user_id | details |\n");
203 out.push_str("|----|-----------|--------|-------------|---------|---------|\n");
204 for e in entries {
205 let _ = writeln!(
206 out,
207 "| {} | {} | {} | {} | {} | {} |",
208 e.id,
209 md_escape(&e.timestamp),
210 md_escape(&e.action),
211 md_escape(&e.tracking_id),
212 e.user_id
213 .map_or_else(|| "—".to_owned(), |id| id.to_string()),
214 md_escape(e.details.as_deref().unwrap_or("")),
215 );
216 }
217 out
218}
219
220fn md_escape(value: &str) -> String {
222 value
223 .replace('\\', r"\\")
224 .replace('|', r"\|")
225 .replace('\n', " ")
226 .replace('\r', "")
227}
228
229fn to_csv(entries: &[AuditLogEntry]) -> String {
231 use std::fmt::Write as _;
232
233 let mut out = String::with_capacity(128 + entries.len() * 100);
234 out.push_str("id,timestamp,action,tracking_id,user_id,details\r\n");
235 for e in entries {
236 let _ = write!(
237 out,
238 "{},{},{},{},{},{}\r\n",
239 e.id,
240 csv_quote(&e.timestamp),
241 csv_quote(&e.action),
242 csv_quote(&e.tracking_id),
243 e.user_id.map_or(String::new(), |id| id.to_string()),
244 csv_quote(e.details.as_deref().unwrap_or("")),
245 );
246 }
247 out
248}
249
250fn csv_quote(value: &str) -> String {
253 let needs_quoting = value.chars().any(|c| matches!(c, ',' | '"' | '\r' | '\n'));
254 if needs_quoting {
255 format!("\"{}\"", value.replace('"', "\"\""))
256 } else {
257 value.to_owned()
258 }
259}
260
261fn to_sarif(entries: &[AuditLogEntry]) -> serde_json::Value {
268 use serde_json::json;
269
270 let mut rule_ids: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
273 rule_ids.sort_unstable();
274 rule_ids.dedup();
275
276 let rules: Vec<serde_json::Value> = rule_ids
277 .iter()
278 .map(|rule_id| {
279 json!({
280 "id": rule_id,
281 "name": rule_id,
282 "shortDescription": { "text": format!("Audit event: {rule_id}") },
283 "fullDescription": { "text": format!("CSAF CRUD audit-log action '{rule_id}'.") },
284 "defaultConfiguration": { "level": default_level_for_action(rule_id) },
285 })
286 })
287 .collect();
288
289 let results: Vec<serde_json::Value> = entries
290 .iter()
291 .map(|e| {
292 let mut props = serde_json::Map::new();
293 props.insert("timestamp".to_owned(), json!(e.timestamp));
294 props.insert("audit_id".to_owned(), json!(e.id));
295 if let Some(uid) = e.user_id {
296 props.insert("user_id".to_owned(), json!(uid));
297 }
298 if let Some(details) = &e.details {
299 props.insert("details".to_owned(), json!(details));
300 }
301
302 json!({
303 "ruleId": e.action,
304 "level": default_level_for_action(&e.action),
305 "message": {
306 "text": format!(
307 "{action} {tracking} at {ts}",
308 action = e.action,
309 tracking = e.tracking_id,
310 ts = e.timestamp,
311 )
312 },
313 "properties": props,
314 })
315 })
316 .collect();
317
318 json!({
319 "$schema": SARIF_SCHEMA,
320 "version": "2.1.0",
321 "runs": [{
322 "tool": {
323 "driver": {
324 "name": SARIF_DRIVER_NAME,
325 "version": env!("CARGO_PKG_VERSION"),
326 "informationUri": "https://gitlab.com/vPierre/ndaal_public_csaf_crud",
327 "rules": rules,
328 }
329 },
330 "results": results,
331 }]
332 })
333}
334
335fn default_level_for_action(action: &str) -> &'static str {
341 match action {
342 "delete" | "update" | "settings_reset" => "warning",
343 _ => "note",
344 }
345}
346
347fn validate_sarif(log: &serde_json::Value) -> Result<()> {
355 let invalid = |msg: &str| -> CsafError { CsafError::Config(format!("SARIF invalid: {msg}")) };
356
357 let Some(version) = log.get("version").and_then(|v| v.as_str()) else {
358 return Err(invalid("missing `version`"));
359 };
360 if version != "2.1.0" {
361 return Err(invalid("version must be '2.1.0'"));
362 }
363 if log.get("$schema").and_then(|v| v.as_str()).is_none() {
364 return Err(invalid("missing `$schema`"));
365 }
366
367 let Some(runs) = log.get("runs").and_then(|v| v.as_array()) else {
368 return Err(invalid("`runs` must be an array"));
369 };
370 let [run] = runs.as_slice() else {
371 return Err(invalid("exactly one run expected"));
372 };
373 if run.pointer("/tool/driver/name").is_none() {
374 return Err(invalid("missing `tool.driver.name`"));
375 }
376
377 if let Some(results) = run.get("results").and_then(|v| v.as_array()) {
378 for (idx, r) in results.iter().enumerate() {
379 if r.get("ruleId").and_then(|v| v.as_str()).is_none() {
380 return Err(invalid(&format!("result[{idx}] missing ruleId")));
381 }
382 if r.pointer("/message/text")
383 .and_then(|v| v.as_str())
384 .is_none()
385 {
386 return Err(invalid(&format!("result[{idx}] missing message.text")));
387 }
388 let level = r.get("level").and_then(|v| v.as_str()).unwrap_or("");
389 if !matches!(level, "none" | "note" | "warning" | "error") {
390 return Err(invalid(&format!(
391 "result[{idx}] has unknown level `{level}`"
392 )));
393 }
394 }
395 }
396 Ok(())
397}
398
399#[cfg(test)]
400#[allow(clippy::case_sensitive_file_extension_comparisons)]
404mod tests {
405 use super::*;
406
407 fn seed_entries(count: usize) -> Vec<AuditLogEntry> {
408 (0..count)
409 .map(|i| {
410 let id = i64::try_from(i).unwrap_or(i64::MAX);
415 AuditLogEntry {
416 id,
417 timestamp: format!("2026-04-23T00:00:{i:02}Z"),
418 action: if i % 3 == 0 {
419 "create".to_owned()
420 } else if i % 3 == 1 {
421 "update".to_owned()
422 } else {
423 "delete".to_owned()
424 },
425 tracking_id: format!("ndaal-sa-2026-{i:03}"),
426 user_id: if i % 2 == 0 { Some(id) } else { None },
427 details: if i % 4 == 0 {
428 Some(format!("row {i}"))
429 } else {
430 None
431 },
432 }
433 })
434 .collect()
435 }
436
437 #[test]
438 fn test_to_markdown_header_and_rows() {
439 let entries = seed_entries(3);
440 let md = to_markdown(&entries);
441 assert!(md.starts_with("# Audit Log Export"));
442 assert!(md.contains("Total rows: **3**"));
443 assert!(md.contains("| id | timestamp | action | tracking_id | user_id | details |"));
444 assert!(md.contains("ndaal-sa-2026-000"));
445 assert!(md.contains("ndaal-sa-2026-002"));
446 }
447
448 #[test]
449 fn test_to_markdown_escapes_pipe_and_newline() {
450 let e = AuditLogEntry {
451 id: 1,
452 timestamp: "2026-04-23T00:00:00Z".to_owned(),
453 action: "create".to_owned(),
454 tracking_id: "ndaal-sa-2026-001".to_owned(),
455 user_id: None,
456 details: Some("a | b\nc".to_owned()),
457 };
458 let md = to_markdown(std::slice::from_ref(&e));
459 assert!(md.contains(r"a \| b c"));
461 assert!(!md.contains("a | b\nc"));
462 }
463
464 #[test]
465 fn test_to_csv_rfc4180_quoting() {
466 let e = AuditLogEntry {
467 id: 1,
468 timestamp: "2026-04-23T00:00:00Z".to_owned(),
469 action: "create".to_owned(),
470 tracking_id: "ndaal-sa-2026-001".to_owned(),
471 user_id: Some(42),
472 details: Some(r#"has,comma and "quote""#.to_owned()),
473 };
474 let csv = to_csv(std::slice::from_ref(&e));
475 assert!(csv.starts_with("id,timestamp,action,tracking_id,user_id,details\r\n"));
476 assert!(csv.contains(r#""has,comma and ""quote"""#));
478 assert!(csv.ends_with("\r\n"));
480 }
481
482 #[test]
483 fn test_to_sarif_passes_self_validation() {
484 let entries = seed_entries(5);
485 let sarif = to_sarif(&entries);
486 validate_sarif(&sarif).expect("self-produced SARIF must validate");
487
488 assert_eq!(sarif["version"], "2.1.0");
489 assert!(sarif["$schema"].is_string());
490 assert_eq!(sarif["runs"][0]["tool"]["driver"]["name"], "csaf-crud");
491 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 5);
492 }
493
494 #[test]
495 fn test_to_sarif_levels() {
496 let entries = vec![
497 AuditLogEntry {
498 id: 1,
499 timestamp: "t".to_owned(),
500 action: "create".to_owned(),
501 tracking_id: "x".to_owned(),
502 user_id: None,
503 details: None,
504 },
505 AuditLogEntry {
506 id: 2,
507 timestamp: "t".to_owned(),
508 action: "delete".to_owned(),
509 tracking_id: "x".to_owned(),
510 user_id: None,
511 details: None,
512 },
513 AuditLogEntry {
514 id: 3,
515 timestamp: "t".to_owned(),
516 action: "settings_reset".to_owned(),
517 tracking_id: "all".to_owned(),
518 user_id: None,
519 details: None,
520 },
521 ];
522 let sarif = to_sarif(&entries);
523 assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
524 assert_eq!(sarif["runs"][0]["results"][1]["level"], "warning");
525 assert_eq!(sarif["runs"][0]["results"][2]["level"], "warning");
526 }
527
528 #[test]
529 fn test_validate_sarif_rejects_bad_version() {
530 let bad = serde_json::json!({
531 "version": "1.0",
532 "$schema": "https://example/sarif",
533 "runs": [{ "tool": { "driver": { "name": "x" } } }],
534 });
535 assert!(validate_sarif(&bad).is_err());
536 }
537
538 #[test]
539 fn test_validate_sarif_rejects_missing_rule_id() {
540 let bad = serde_json::json!({
541 "version": "2.1.0",
542 "$schema": "https://example/sarif",
543 "runs": [{
544 "tool": { "driver": { "name": "x" } },
545 "results": [{ "message": { "text": "m" }, "level": "note" }],
546 }],
547 });
548 assert!(validate_sarif(&bad).is_err());
549 }
550
551 #[test]
552 fn test_export_audit_log_writes_four_payloads_and_sidecars() {
553 let pool = DbPool::open_in_memory().expect("db open");
554 pool.with_conn(|conn| {
555 audit_log::record(conn, "create", "ndaal-sa-2026-001", None, None)?;
556 audit_log::record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
557 audit_log::record(conn, "settings_reset", "all", None, None)
558 })
559 .expect("seed audit_log");
560
561 let out = tempfile::tempdir().expect("tmp");
562 let settings = Settings::default(); let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
564 .expect("export ok");
565
566 assert_eq!(res.rows, 3);
567 assert_eq!(res.written.len(), 4);
568 assert_eq!(res.sidecars.len(), 12);
570
571 for path in &res.written {
572 assert!(path.exists(), "missing payload {}", path.display());
573 }
574 for side in &res.sidecars {
575 assert!(side.exists(), "missing sidecar {}", side.display());
576 let name = side.file_name().unwrap().to_string_lossy();
577 assert!(
578 name.ends_with(".sha-256")
579 || name.ends_with(".sha-512")
580 || name.ends_with(".sha3-512"),
581 "unexpected sidecar extension: {name}"
582 );
583 assert!(!name.ends_with(".sha256"));
584 assert!(!name.ends_with(".sha512"));
585 }
586 }
587
588 #[test]
589 fn test_export_audit_log_respects_format_opts() {
590 let pool = DbPool::open_in_memory().expect("db open");
591 pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
592 .expect("seed");
593
594 let out = tempfile::tempdir().expect("tmp");
595 let opts = AuditExportOptions {
596 markdown: false,
597 csv: true,
598 json: false,
599 sarif: true,
600 };
601 let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
602 assert_eq!(res.written.len(), 2);
603 let names: Vec<String> = res
604 .written
605 .iter()
606 .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
607 .collect();
608 assert!(names.iter().any(|n| n.ends_with(".csv")));
609 assert!(names.iter().any(|n| n.ends_with(".sarif")));
610 assert!(!names.iter().any(|n| n.ends_with(".md")));
611 assert!(!names.iter().any(|n| n.ends_with(".json")));
612 }
613
614 #[test]
615 fn test_export_audit_log_respects_sidecar_toggles() {
616 let pool = DbPool::open_in_memory().expect("db open");
617 pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
618 .expect("seed");
619
620 let out = tempfile::tempdir().expect("tmp");
621 let settings = Settings {
622 sidecar_sha256: true,
623 sidecar_sha512: false,
624 sidecar_sha3_512: true,
625 ..Settings::default()
626 };
627 let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
628 .expect("ok");
629
630 assert_eq!(res.sidecars.len(), 8);
632 for side in &res.sidecars {
633 let name = side.file_name().unwrap().to_string_lossy();
634 assert!(
635 name.ends_with(".sha-256") || name.ends_with(".sha3-512"),
636 "sha-512 must be skipped: {name}"
637 );
638 }
639 }
640
641 #[test]
642 fn test_export_audit_log_creates_missing_dir() {
643 let pool = DbPool::open_in_memory().expect("db open");
644 let tmp = tempfile::tempdir().expect("tmp");
645 let nested = tmp.path().join("a/b/c");
646 assert!(!nested.exists());
647
648 export_audit_log(
649 &pool,
650 &nested,
651 &Settings::default(),
652 &AuditExportOptions::default(),
653 )
654 .expect("ok");
655 assert!(nested.exists());
656 }
657
658 #[test]
659 fn test_export_audit_log_empty_table() {
660 let pool = DbPool::open_in_memory().expect("db open");
661 let out = tempfile::tempdir().expect("tmp");
662 let res = export_audit_log(
663 &pool,
664 out.path(),
665 &Settings::default(),
666 &AuditExportOptions::default(),
667 )
668 .expect("ok");
669 assert_eq!(res.rows, 0);
670 assert_eq!(res.written.len(), 4);
671 assert_eq!(res.sidecars.len(), 12);
673 }
674
675 #[test]
676 fn test_export_audit_log_json_roundtrip() {
677 let pool = DbPool::open_in_memory().expect("db open");
678 pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, Some("x")))
679 .expect("seed");
680
681 let out = tempfile::tempdir().expect("tmp");
682 let opts = AuditExportOptions {
683 markdown: false,
684 csv: false,
685 json: true,
686 sarif: false,
687 };
688 let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
689
690 let bytes = std::fs::read(&res.written[0]).expect("read");
691 let parsed: Vec<AuditLogEntry> = serde_json::from_slice(&bytes).expect("parse");
692 assert_eq!(parsed.len(), 1);
693 assert_eq!(parsed[0].action, "create");
694 }
695}