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