1use csaf_models::csaf_document::CsafDocument;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Severity {
14 Error,
16 Warning,
18}
19
20#[derive(Debug, Clone)]
22pub struct ValidationError {
23 pub path: String,
25 pub severity: Severity,
27 pub message: String,
29}
30
31#[must_use]
35pub fn validate(doc: &CsafDocument) -> Vec<ValidationError> {
36 let mut errors = Vec::new();
37
38 validate_document_metadata(doc, &mut errors);
39 validate_tracking(doc, &mut errors);
40 validate_publisher(doc, &mut errors);
41 validate_product_tree(doc, &mut errors);
42 validate_vulnerabilities(doc, &mut errors);
43 validate_product_id_references(doc, &mut errors);
44
45 errors
46}
47
48#[must_use]
50pub fn is_valid(doc: &CsafDocument) -> bool {
51 validate(doc).iter().all(|e| e.severity != Severity::Error)
52}
53
54fn validate_document_metadata(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
60 let valid_categories = [
62 "csaf_security_advisory",
63 "csaf_vex",
64 "csaf_informational_advisory",
65 "csaf_base",
66 ];
67 if !valid_categories.contains(&doc.document.category.as_str()) {
68 errors.push(ValidationError {
69 path: "$.document.category".to_owned(),
70 severity: Severity::Error,
71 message: format!(
72 "Invalid category '{}'. Must be one of: {valid_categories:?}",
73 doc.document.category
74 ),
75 });
76 }
77
78 if doc.document.csaf_version != "2.0" && doc.document.csaf_version != "2.1" {
80 errors.push(ValidationError {
81 path: "$.document.csaf_version".to_owned(),
82 severity: Severity::Error,
83 message: format!(
84 "Invalid CSAF version '{}'. Must be '2.0' or '2.1'",
85 doc.document.csaf_version
86 ),
87 });
88 }
89
90 if doc.document.title.trim().is_empty() {
92 errors.push(ValidationError {
93 path: "$.document.title".to_owned(),
94 severity: Severity::Error,
95 message: "Document title must not be empty".to_owned(),
96 });
97 }
98}
99
100fn validate_tracking(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
102 let tracking = &doc.document.tracking;
103
104 if tracking.id.trim().is_empty() {
106 errors.push(ValidationError {
107 path: "$.document.tracking.id".to_owned(),
108 severity: Severity::Error,
109 message: "Tracking ID must not be empty".to_owned(),
110 });
111 }
112
113 let valid_statuses = ["draft", "interim", "final"];
115 if !valid_statuses.contains(&tracking.status.as_str()) {
116 errors.push(ValidationError {
117 path: "$.document.tracking.status".to_owned(),
118 severity: Severity::Error,
119 message: format!(
120 "Invalid status '{}'. Must be one of: {valid_statuses:?}",
121 tracking.status
122 ),
123 });
124 }
125
126 if tracking.version.trim().is_empty() {
128 errors.push(ValidationError {
129 path: "$.document.tracking.version".to_owned(),
130 severity: Severity::Error,
131 message: "Version must not be empty".to_owned(),
132 });
133 }
134
135 if chrono::DateTime::parse_from_rfc3339(&tracking.current_release_date).is_err()
137 && chrono::NaiveDateTime::parse_from_str(
138 &tracking.current_release_date,
139 "%Y-%m-%dT%H:%M:%S%.fZ",
140 )
141 .is_err()
142 {
143 errors.push(ValidationError {
144 path: "$.document.tracking.current_release_date".to_owned(),
145 severity: Severity::Warning,
146 message: format!(
147 "Date '{}' may not be valid ISO 8601",
148 tracking.current_release_date
149 ),
150 });
151 }
152
153 if tracking.status == "final" && tracking.revision_history.is_empty() {
155 errors.push(ValidationError {
156 path: "$.document.tracking.revision_history".to_owned(),
157 severity: Severity::Warning,
158 message: "Final documents should have at least one revision history entry".to_owned(),
159 });
160 }
161}
162
163fn validate_publisher(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
165 let publisher = &doc.document.publisher;
166
167 if publisher.name.trim().is_empty() {
168 errors.push(ValidationError {
169 path: "$.document.publisher.name".to_owned(),
170 severity: Severity::Error,
171 message: "Publisher name must not be empty".to_owned(),
172 });
173 }
174
175 if publisher.namespace.trim().is_empty() {
176 errors.push(ValidationError {
177 path: "$.document.publisher.namespace".to_owned(),
178 severity: Severity::Error,
179 message: "Publisher namespace must not be empty".to_owned(),
180 });
181 }
182
183 let valid_categories = [
184 "vendor",
185 "discoverer",
186 "coordinator",
187 "user",
188 "other",
189 "translator",
190 ];
191 if !valid_categories.contains(&publisher.category.as_str()) {
192 errors.push(ValidationError {
193 path: "$.document.publisher.category".to_owned(),
194 severity: Severity::Error,
195 message: format!(
196 "Invalid publisher category '{}'. Must be one of: {valid_categories:?}",
197 publisher.category
198 ),
199 });
200 }
201}
202
203fn validate_product_tree(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
205 let product_ids = doc.all_product_ids();
206
207 if product_ids.is_empty()
208 && doc.product_tree.full_product_names.is_empty()
209 && doc.product_tree.branches.is_empty()
210 {
211 errors.push(ValidationError {
212 path: "$.product_tree".to_owned(),
213 severity: Severity::Warning,
214 message: "Product tree has no branches or product names".to_owned(),
215 });
216 }
217
218 let mut seen = std::collections::HashSet::new();
220 for id in &product_ids {
221 if !seen.insert(id.as_str()) {
222 errors.push(ValidationError {
223 path: "$.product_tree".to_owned(),
224 severity: Severity::Error,
225 message: format!("Duplicate product ID: {id}"),
226 });
227 }
228 }
229}
230
231fn validate_vulnerabilities(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
233 for (i, vuln) in doc.vulnerabilities.iter().enumerate() {
234 let prefix = format!("$.vulnerabilities[{i}]");
235
236 for (j, metric) in vuln.metrics.iter().enumerate() {
238 if let Some(v3) = &metric.content.cvss_v3 {
239 if !(0.0..=10.0).contains(&v3.base_score) {
240 errors.push(ValidationError {
241 path: format!("{prefix}.metrics[{j}].content.cvss_v3.baseScore"),
242 severity: Severity::Error,
243 message: format!(
244 "CVSS v3 baseScore {} out of range [0.0, 10.0]",
245 v3.base_score
246 ),
247 });
248 }
249
250 if !v3.vector_string.starts_with("CVSS:3") {
251 errors.push(ValidationError {
252 path: format!("{prefix}.metrics[{j}].content.cvss_v3.vectorString"),
253 severity: Severity::Error,
254 message: format!(
255 "CVSS v3 vectorString must start with 'CVSS:3': {}",
256 v3.vector_string
257 ),
258 });
259 }
260
261 validate_cvss_severity(
262 v3.base_score,
263 &v3.base_severity,
264 &format!("{prefix}.metrics[{j}].content.cvss_v3"),
265 errors,
266 );
267 }
268
269 if let Some(v4) = &metric.content.cvss_v4 {
270 if !(0.0..=10.0).contains(&v4.base_score) {
271 errors.push(ValidationError {
272 path: format!("{prefix}.metrics[{j}].content.cvss_v4.baseScore"),
273 severity: Severity::Error,
274 message: format!(
275 "CVSS v4 baseScore {} out of range [0.0, 10.0]",
276 v4.base_score
277 ),
278 });
279 }
280
281 if !v4.vector_string.starts_with("CVSS:4") {
282 errors.push(ValidationError {
283 path: format!("{prefix}.metrics[{j}].content.cvss_v4.vectorString"),
284 severity: Severity::Error,
285 message: format!(
286 "CVSS v4 vectorString must start with 'CVSS:4': {}",
287 v4.vector_string
288 ),
289 });
290 }
291
292 validate_cvss_severity(
293 v4.base_score,
294 &v4.base_severity,
295 &format!("{prefix}.metrics[{j}].content.cvss_v4"),
296 errors,
297 );
298 }
299 }
300 }
301}
302
303fn validate_cvss_severity(
305 score: f64,
306 severity: &str,
307 path: &str,
308 errors: &mut Vec<ValidationError>,
309) {
310 let expected = if score == 0.0 {
311 "NONE"
312 } else if score <= 3.9 {
313 "LOW"
314 } else if score <= 6.9 {
315 "MEDIUM"
316 } else if score <= 8.9 {
317 "HIGH"
318 } else {
319 "CRITICAL"
320 };
321
322 if severity != expected {
323 errors.push(ValidationError {
324 path: format!("{path}.baseSeverity"),
325 severity: Severity::Warning,
326 message: format!(
327 "baseSeverity '{severity}' does not match baseScore {score} (expected '{expected}')"
328 ),
329 });
330 }
331}
332
333fn validate_product_id_references(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
335 let defined_ids: std::collections::HashSet<String> =
336 doc.all_product_ids().into_iter().collect();
337
338 for (i, vuln) in doc.vulnerabilities.iter().enumerate() {
339 let prefix = format!("$.vulnerabilities[{i}]");
340
341 if let Some(status) = &vuln.product_status {
343 for field_name in &[
344 "known_affected",
345 "known_not_affected",
346 "fixed",
347 "under_investigation",
348 ] {
349 let ids = match *field_name {
350 "known_affected" => &status.known_affected,
351 "known_not_affected" => &status.known_not_affected,
352 "fixed" => &status.fixed,
353 "under_investigation" => &status.under_investigation,
354 _ => continue,
355 };
356 for id in ids {
357 if !defined_ids.contains(id) {
358 errors.push(ValidationError {
359 path: format!("{prefix}.product_status.{field_name}"),
360 severity: Severity::Error,
361 message: format!("Product ID '{id}' not found in product_tree"),
362 });
363 }
364 }
365 }
366 }
367
368 for (j, metric) in vuln.metrics.iter().enumerate() {
370 for id in &metric.products {
371 if !defined_ids.contains(id) {
372 errors.push(ValidationError {
373 path: format!("{prefix}.metrics[{j}].products"),
374 severity: Severity::Error,
375 message: format!("Product ID '{id}' not found in product_tree"),
376 });
377 }
378 }
379 }
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn test_validate_test_file_003() {
389 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
390 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
391 let errors = validate(&doc);
392
393 let hard_errors: Vec<_> = errors
394 .iter()
395 .filter(|e| e.severity == Severity::Error)
396 .collect();
397 assert!(
398 hard_errors.is_empty(),
399 "Test file 003 should have no errors: {hard_errors:?}"
400 );
401 }
402
403 #[test]
404 fn test_validate_all_test_files() {
405 let test_dir =
406 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
407
408 for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
409 let entry = entry.expect("dir entry error");
410 if !entry.file_type().expect("type error").is_dir() {
411 continue;
412 }
413 for file in std::fs::read_dir(entry.path()).expect("subdir error") {
414 let file = file.expect("file error");
415 let path = file.path();
416 if path.extension().is_some_and(|e| e == "json") {
417 let content = std::fs::read_to_string(&path).expect("read error");
418 let doc: CsafDocument = serde_json::from_str(&content).expect("parse error");
419 let errors = validate(&doc);
420 let hard_errors: Vec<_> = errors
421 .iter()
422 .filter(|e| e.severity == Severity::Error)
423 .collect();
424 assert!(
425 hard_errors.is_empty(),
426 "File {} has validation errors: {hard_errors:?}",
427 path.display()
428 );
429 }
430 }
431 }
432 }
433
434 #[test]
435 fn test_validate_empty_title() {
436 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
437 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
438 doc.document.title = String::new();
439
440 let errors = validate(&doc);
441 assert!(errors.iter().any(|e| e.path == "$.document.title"));
442 }
443
444 #[test]
445 fn test_validate_invalid_category() {
446 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
447 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
448 doc.document.category = "invalid_category".to_owned();
449
450 let errors = validate(&doc);
451 assert!(
452 errors
453 .iter()
454 .any(|e| e.path == "$.document.category" && e.severity == Severity::Error)
455 );
456 }
457
458 #[test]
459 fn test_validate_invalid_csaf_version() {
460 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
461 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
462 doc.document.csaf_version = "3.0".to_owned();
463
464 let errors = validate(&doc);
465 assert!(errors.iter().any(|e| e.path == "$.document.csaf_version"));
466 }
467
468 #[test]
469 fn test_validate_cvss_score_out_of_range() {
470 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
471 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
472 if let Some(metric) = doc.vulnerabilities[0].metrics.first_mut()
473 && let Some(v3) = metric.content.cvss_v3.as_mut()
474 {
475 v3.base_score = 11.0;
476 }
477
478 let errors = validate(&doc);
479 assert!(errors.iter().any(|e| e.message.contains("out of range")));
480 }
481
482 #[test]
483 fn test_validate_missing_product_id_reference() {
484 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
485 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
486 if let Some(status) = doc.vulnerabilities[0].product_status.as_mut() {
487 status.known_affected.push("NONEXISTENT-001".to_owned());
488 }
489
490 let errors = validate(&doc);
491 assert!(errors.iter().any(|e| e.message.contains("NONEXISTENT-001")));
492 }
493
494 #[test]
495 fn test_is_valid_returns_true_for_valid_doc() {
496 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
497 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
498 assert!(is_valid(&doc));
499 }
500}