diff --git a/src/interop.rs b/src/interop.rs index 649f046..6b81ffa 100644 --- a/src/interop.rs +++ b/src/interop.rs @@ -189,8 +189,7 @@ pub mod rustsec { println!("INVALID Product ID"); vec![ProductIdT("INVALID".to_string())] }), - cvss_v2: None, - cvss_v3: Some(b), + cvss_scores: vec![b.into()], }] }), threats: None, diff --git a/src/lib.rs b/src/lib.rs index 36717f4..198ca16 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -102,4 +102,39 @@ mod tests { let document: Csaf = serde_json_round_trip(example); println!("{:#?}", document); } + + #[test] + fn cvss_example_deserializes() { + let example = include_str!("../tests/ssa-054046.json"); + let document: Csaf = serde_json::from_str(example).expect("Failed to deserialize JSON"); + + // Check vulnerabilities + let vulns = document + .vulnerabilities + .as_ref() + .expect("Expected vulnerabilities to be present"); + assert_eq!(vulns.len(), 1, "Expected exactly one vulnerability"); + + // Check scores + let scores = vulns[0] + .scores + .as_ref() + .expect("Expected scores to be present"); + assert_eq!(scores.len(), 1, "Expected exactly one score"); + + // Check CVSS score + let cvss_scores = &scores[0].cvss_scores; + assert_eq!(cvss_scores.len(), 1, "Expected exactly one CVSS score"); + + // Verify baseSeverity + assert_eq!( + cvss_scores[0] + .raw + .get("baseSeverity") + .and_then(|v| v.as_str()) + .expect("Expected baseSeverity to be present"), + "MEDIUM", + "Expected CVSS score baseSeverity to be MEDIUM" + ); + } } diff --git a/src/vulnerability.rs b/src/vulnerability.rs index 077e3a3..08598d0 100644 --- a/src/vulnerability.rs +++ b/src/vulnerability.rs @@ -1,6 +1,9 @@ use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, TryFromInto}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::Map; +use serde_json::{json, Value}; +use serde_with::serde_as; +use std::str::FromStr; use url::Url; use crate::definitions::{AcknowledgmentsT, NotesT, ProductGroupsT, ProductsT, ReferencesT}; @@ -161,72 +164,203 @@ pub enum RestartCategory { /// [Score](https://github.com/oasis-tcs/csaf/blob/master/csaf_2.0/prose/csaf-v2-editor-draft.md#32312-vulnerabilities-property---scores) #[serde_as] #[serde_with::skip_serializing_none] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Score { pub products: ProductsT, - // TODO: A CVSS v2 representation, or just document it as out of scope - // TODO: Should have at least one of: - pub cvss_v2: Option, - #[serde_as(as = "Option>")] - pub cvss_v3: Option, -} - -mod cvss_json { - use std::str::FromStr; - - use serde::{Deserialize, Serialize, Serializer}; - - /// CVSSv3 JSON Representation - /// - /// An internal representation of a CVSS score, meant to be serializable to JSON as specified in - /// [https://www.first.org/cvss/cvss-v3.1.json](https://www.first.org/cvss/cvss-v3.1.json). - /// - /// Use with [cvss::v3::Base] and the provided `From` implementation. - #[derive(Serialize, Deserialize, Debug, Clone)] - #[serde(rename_all = "camelCase")] - pub(crate) struct Cvss3 { - // TODO: Should be able to store just the Base and generate the rest of this at serialize time? - version: Cvss3Version, - vector_string: String, - base_score: f64, - #[serde(serialize_with = "severity_to_upper")] - base_severity: cvss::Severity, + // Collection of CVSS scores (raw and parsed) + pub cvss_scores: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CvssScore { + // CVSS version (e.g., 2.0, 3.0, 3.1, 4.0, or unknown) + pub version: CvssVersion, + // Raw JSON data for the CVSS score + pub raw: Value, + // Parsed CVSS score, if parsing was successful + pub parsed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CvssVersion { + #[serde(rename = "2.0")] + V2_0, + #[serde(rename = "3.0")] + V3_0, + #[serde(rename = "3.1")] + V3_1, + #[serde(untagged)] + Unknown(String), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub enum ParsedCvss { + V3(cvss::v3::Base), // Parsed CVSS v3 (3.0 or 3.1) +} + +impl From for CvssScore { + fn from(base: cvss::v3::Base) -> Self { + // Attempt to serialize the base back to JSON (used for raw) + let raw = serde_json::to_value(&base).unwrap_or(Value::Null); + + // Detect version from vector string + let version = match base.minor_version { + 0 => CvssVersion::V3_0, + 1 => CvssVersion::V3_1, + _ => CvssVersion::Unknown(format!("3.{}", base.minor_version)), + }; + + CvssScore { + version, + raw, + parsed: Some(ParsedCvss::V3(base)), + } } +} + +pub fn cvss_base_to_json(base: &cvss::v3::Base) -> Value { + let version_str = match base.minor_version { + 0 => "3.0", + 1 => "3.1", + _ => &format!("3.{}", base.minor_version), + }; + + json!({ + "version": version_str, + "vectorString": base.to_string(), + "baseScore": base.score().value(), + "baseSeverity": base.severity().as_str().to_uppercase(), + }) +} - // https://www.first.org/cvss/cvss-v3.1.json requires baseSeverity field to be uppercase. - // cvss crate normalizes to lowercase. - fn severity_to_upper(severity: &cvss::Severity, serializer: S) -> Result +impl<'de> Deserialize<'de> for Score { + fn deserialize(deserializer: D) -> Result where - S: Serializer, + D: Deserializer<'de>, { - serializer.serialize_str(&severity.as_str().to_uppercase()) - } + #[derive(Deserialize)] + struct RawScore { + products: ProductsT, + #[serde(default)] + cvss_v2: Option, + #[serde(default)] + cvss_v3: Option, + } - impl From for Cvss3 { - fn from(b: cvss::v3::Base) -> Self { - Self { - version: Cvss3Version::ThreeDotOne, - vector_string: format!("{b}"), - base_score: b.score().value(), - base_severity: b.severity(), + let raw_score = RawScore::deserialize(deserializer)?; + let mut cvss_scores = Vec::new(); + let keep_fields = ["version", "vectorString", "baseScore", "baseSeverity"]; + + // Helper function to filter fields + fn filter_fields(value: &Value, keys: &[&str]) -> Value { + let mut filtered = Map::new(); + if let Value::Object(obj) = value { + for &key in keys { + if let Some(val) = obj.get(key) { + filtered.insert(key.to_string(), val.clone()); + } + } } + Value::Object(filtered) } - } - impl TryFrom for cvss::v3::Base { - type Error = ::Err; + // Process cvss_v2 if present + if let Some(v2) = raw_score.cvss_v2 { + let version = v2 + .get("version") + .and_then(|v| v.as_str()) + .map(|v| match v { + "2.0" => CvssVersion::V2_0, + other => CvssVersion::Unknown(other.to_string()), + }) + .unwrap_or(CvssVersion::Unknown(String::new())); + cvss_scores.push(CvssScore { + version, + raw: filter_fields(&v2, &keep_fields), + parsed: None, // CVSS v2 not parsed + }); + } - fn try_from(b: Cvss3) -> Result { - cvss::v3::Base::from_str(&b.vector_string) + // Process cvss_v3 if present + if let Some(v3) = raw_score.cvss_v3 { + let version = v3 + .get("version") + .and_then(|v| v.as_str()) + .map(|v| match v { + "3.0" => CvssVersion::V3_0, + "3.1" => CvssVersion::V3_1, + other => CvssVersion::Unknown(other.to_string()), + }) + .unwrap_or(CvssVersion::Unknown(String::new())); + let parsed = match version { + CvssVersion::V3_0 | CvssVersion::V3_1 => { + match v3.get("vectorString").and_then(|v| v.as_str()) { + Some(vector) => match cvss::v3::Base::from_str(vector) { + Ok(base) => Some(ParsedCvss::V3(base)), + Err(_) => None, // Parsing failed + }, + None => None, // Invalid JSON structure + } + } + _ => None, // Unsupported version + }; + cvss_scores.push(CvssScore { + version, + raw: filter_fields(&v3, &keep_fields), + parsed, + }); } + + Ok(Score { + products: raw_score.products, + cvss_scores, + }) } +} + +impl Serialize for Score { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + struct RawScore<'a> { + products: &'a ProductsT, + #[serde(skip_serializing_if = "Option::is_none")] + cvss_v2: Option<&'a Value>, + #[serde(skip_serializing_if = "Option::is_none")] + cvss_v3: Option, + } + + let mut cvss_v2 = None; + let mut cvss_v3 = None; - #[derive(Serialize, Deserialize, Debug, Clone)] - enum Cvss3Version { - #[serde(rename = "3.1")] - ThreeDotOne, - #[serde(rename = "3.0")] - ThreeDotZero, + // Select the first CvssScore for each version + for score in &self.cvss_scores { + match score.version { + CvssVersion::V2_0 | CvssVersion::Unknown(_) => { + if cvss_v2.is_none() { + cvss_v2 = Some(&score.raw); + } + } + CvssVersion::V3_0 | CvssVersion::V3_1 => { + if cvss_v3.is_none() { + cvss_v3 = Some(match &score.parsed { + Some(ParsedCvss::V3(base)) => cvss_base_to_json(base), + None => score.raw.clone(), + }); + } + } + } + } + + RawScore { + products: &self.products, + cvss_v2, + cvss_v3, + } + .serialize(serializer) } } diff --git a/tests/ssa-054046.json b/tests/ssa-054046.json new file mode 100644 index 0000000..c1416e5 --- /dev/null +++ b/tests/ssa-054046.json @@ -0,0 +1,3147 @@ +{ + "document": { + "category": "csaf_security_advisory", + "csaf_version": "2.0", + "distribution": { + "text": "Disclosure is not limited. (TLPv2: TLP:CLEAR)", + "tlp": { + "label": "WHITE" + } + }, + "lang": "en", + "notes": [ + { + "category": "summary", + "text": "Several SIMATIC S7-1500 CPU versions are affected by an authentication bypass vulnerability that could allow an unauthenticated remote attacker to gain knowledge about actual and configured maximum cycle times and communication load of the CPU.\n\nSiemens has released new versions for several affected products and recommends to update to the latest versions. Siemens is preparing further fix versions and recommends countermeasures for products where fixes are not, or not yet available.", + "title": "Summary" + }, + { + "category": "general", + "text": "As a general security measure, Siemens strongly recommends to protect network access to devices with appropriate mechanisms. In order to operate the devices in a protected IT environment, Siemens recommends to configure the environment according to Siemens' operational guidelines for Industrial Security (Download: \nhttps://www.siemens.com/cert/operational-guidelines-industrial-security), and to follow the recommendations in the product manuals.\nAdditional information on Industrial Security by Siemens can be found at: https://www.siemens.com/industrialsecurity", + "title": "General Recommendations" + }, + { + "category": "general", + "text": "For further inquiries on security vulnerabilities in Siemens products and solutions, please contact the Siemens ProductCERT: https://www.siemens.com/cert/advisories", + "title": "Additional Resources" + }, + { + "category": "legal_disclaimer", + "text": "The use of Siemens Security Advisories is subject to the terms and conditions listed on: https://www.siemens.com/productcert/terms-of-use.", + "title": "Terms of Use" + } + ], + "publisher": { + "category": "vendor", + "contact_details": "productcert@siemens.com", + "name": "Siemens ProductCERT", + "namespace": "https://www.siemens.com" + }, + "references": [ + { + "category": "self", + "summary": "SSA-054046: Unauthenticated Information Disclosure in Web Server of SIMATIC S7-1500 CPUs - HTML Version", + "url": "https://cert-portal.siemens.com/productcert/html/ssa-054046.html" + }, + { + "category": "self", + "summary": "SSA-054046: Unauthenticated Information Disclosure in Web Server of SIMATIC S7-1500 CPUs - CSAF Version", + "url": "https://cert-portal.siemens.com/productcert/csaf/ssa-054046.json" + } + ], + "title": "SSA-054046: Unauthenticated Information Disclosure in Web Server of SIMATIC S7-1500 CPUs", + "tracking": { + "current_release_date": "2025-04-08T00:00:00Z", + "generator": { + "engine": { + "name": "Siemens ProductCERT CSAF Generator", + "version": "1" + } + }, + "id": "SSA-054046", + "initial_release_date": "2024-10-08T00:00:00Z", + "revision_history": [ + { + "date": "2024-10-08T00:00:00Z", + "legacy_version": "1.0", + "number": "1", + "summary": "Publication Date" + }, + { + "date": "2024-11-12T00:00:00Z", + "legacy_version": "1.1", + "number": "2", + "summary": "Added fix for SIMATIC S7-1500 CPU 1518-4 PN/DP MFP, CPU 1518-4 PN/DP MFP, CPU 1518F-4 PN/DP MFP, CPU 1518F-4 PN/DP MFP and CPU 1518-4 PN/DP MFP" + }, + { + "date": "2025-01-14T00:00:00Z", + "legacy_version": "1.2", + "number": "3", + "summary": "Added fix for SIMATIC S7-PLCSIM Advanced" + }, + { + "date": "2025-03-11T00:00:00Z", + "legacy_version": "1.3", + "number": "4", + "summary": "Added fix for SIMATIC ET 200SP Open Controller CPU 1515SP PC2 (incl. SIPLUS variants)" + }, + { + "date": "2025-04-08T00:00:00Z", + "legacy_version": "1.4", + "number": "5", + "summary": "Added fix for SIMATIC S7-1500 Software Controller V3 and updated several product names" + } + ], + "status": "interim", + "version": "5" + } + }, + "product_tree": { + "branches": [ + { + "branches": [ + { + "branches": [ + { + "category": "product_version_range", + "name": "vers:all/