Skip to main content

csaf_core/
validation.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Pure Rust CSAF 2.0/2.1 validation.
5//!
6//! Validates CSAF documents against structural and semantic rules without
7//! external validators (avoiding `csaf-walker` which pulls `openssl-sys`).
8
9use csaf_models::csaf_document::CsafDocument;
10
11/// Severity of a validation finding.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Severity {
14    /// A hard error that must be fixed.
15    Error,
16    /// A warning that should be addressed.
17    Warning,
18}
19
20/// A single validation finding.
21#[derive(Debug, Clone)]
22pub struct ValidationError {
23    /// JSON path to the problematic field.
24    pub path: String,
25    /// Severity of the finding.
26    pub severity: Severity,
27    /// Human-readable description.
28    pub message: String,
29}
30
31/// Validate a CSAF document against all rules.
32///
33/// Returns an empty vector if the document is valid.
34#[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/// Check if a document passes validation (no errors, warnings allowed).
49#[must_use]
50pub fn is_valid(doc: &CsafDocument) -> bool {
51    validate(doc).iter().all(|e| e.severity != Severity::Error)
52}
53
54// ---------------------------------------------------------------------------
55// Validation rules
56// ---------------------------------------------------------------------------
57
58/// Validate core document metadata.
59fn validate_document_metadata(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
60    // Category must be a known value.
61    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    // CSAF version must be 2.0 or 2.1.
79    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    // Title must not be empty.
91    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
100/// Validate tracking metadata.
101fn validate_tracking(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
102    let tracking = &doc.document.tracking;
103
104    // Tracking ID must not be empty.
105    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    // Status must be a known value.
114    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    // Version must not be empty.
127    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    // Dates must be valid ISO 8601.
136    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    // Revision history should not be empty for final documents.
154    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
163/// Validate publisher information.
164fn 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
203/// Validate product tree.
204fn 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    // Check for duplicate product IDs.
219    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
231/// Validate vulnerability entries.
232fn 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        // CVSS score validation.
237        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
303/// Validate CVSS severity matches score.
304fn 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
333/// Validate that all product IDs referenced in vulnerabilities exist in the product tree.
334fn 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        // Check product_status references.
342        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        // Check metric product references.
369        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}