Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
}
242 changes: 188 additions & 54 deletions src/vulnerability.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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_json::Value>,
#[serde_as(as = "Option<TryFromInto<cvss_json::Cvss3>>")]
pub cvss_v3: Option<cvss::v3::Base>,
}

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<CvssScore>,
}

#[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<ParsedCvss>,
}

#[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<cvss::v3::Base> 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<S>(severity: &cvss::Severity, serializer: S) -> Result<S::Ok, S::Error>
impl<'de> Deserialize<'de> for Score {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<Value>,
#[serde(default)]
cvss_v3: Option<Value>,
}

impl From<cvss::v3::Base> 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<Cvss3> for cvss::v3::Base {
type Error = <cvss::v3::Base as FromStr>::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<Self, Self::Error> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<Value>,
}

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)
}
}

Expand Down
Loading