use chrono::{DateTime, Utc};
use lazy_regex::regex_switch;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Package {
pub name: String,
pub ecosystem: Ecosystem,
#[serde(skip_serializing_if = "Option::is_none")]
pub purl: Option<String>,
}
pub type Commit = String;
pub type Version = String;
#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Clone)]
#[non_exhaustive]
pub enum Ecosystem {
AlmaLinux(Option<String>),
Alpaquita,
Alpine(Option<String>),
Android,
BellSoftHardenedContainers,
Bioconductor,
Bitnami,
Chainguard,
ConanCenter,
CRAN,
CratesIO,
Debian(Option<String>),
DWF,
Echo,
GHC,
GSD,
GitHubActions,
Go,
Hackage,
Hex,
JavaScript,
Julia,
Kubernetes,
Linux,
Mageia(String),
Maven(String),
MinimOS,
Npm,
NuGet,
OpenEuler,
OpenSUSE(Option<String>),
OssFuzz,
Packagist,
PhotonOS(Option<String>),
Pub,
PyPI,
Python,
RedHat(Option<String>),
RockyLinux(Option<String>),
RubyGems,
SUSE(Option<String>),
SwiftURL,
Ubuntu {
version: String,
metadata: Option<String>,
fips: Option<String>,
pro: bool,
lts: bool,
},
UVI,
VSCode,
Wolfi,
}
impl Serialize for Ecosystem {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
Ecosystem::AlmaLinux(None) => serializer.serialize_str("AlmaLinux"),
Ecosystem::AlmaLinux(Some(release)) => {
serializer.serialize_str(&format!("AlmaLinux:{}", release))
}
Ecosystem::Alpaquita => serializer.serialize_str("Alpaquita"),
Ecosystem::Alpine(None) => serializer.serialize_str("Alpine"),
Ecosystem::Alpine(Some(version)) => {
serializer.serialize_str(&format!("Alpine:{}", version))
}
Ecosystem::Android => serializer.serialize_str("Android"),
Ecosystem::BellSoftHardenedContainers => {
serializer.serialize_str("BellSoft Hardened Containers")
}
Ecosystem::Bioconductor => serializer.serialize_str("Bioconductor"),
Ecosystem::Bitnami => serializer.serialize_str("Bitnami"),
Ecosystem::Chainguard => serializer.serialize_str("Chainguard"),
Ecosystem::ConanCenter => serializer.serialize_str("ConanCenter"),
Ecosystem::CRAN => serializer.serialize_str("CRAN"),
Ecosystem::CratesIO => serializer.serialize_str("crates.io"),
Ecosystem::Debian(None) => serializer.serialize_str("Debian"),
Ecosystem::Debian(Some(version)) => {
serializer.serialize_str(&format!("Debian:{}", version))
}
Ecosystem::DWF => serializer.serialize_str("DWF"),
Ecosystem::Echo => serializer.serialize_str("Echo"),
Ecosystem::GHC => serializer.serialize_str("GHC"),
Ecosystem::GSD => serializer.serialize_str("GSD"),
Ecosystem::GitHubActions => serializer.serialize_str("GitHub Actions"),
Ecosystem::Go => serializer.serialize_str("Go"),
Ecosystem::Hackage => serializer.serialize_str("Hackage"),
Ecosystem::Hex => serializer.serialize_str("Hex"),
Ecosystem::JavaScript => serializer.serialize_str("JavaScript"),
Ecosystem::Julia => serializer.serialize_str("Julia"),
Ecosystem::Kubernetes => serializer.serialize_str("Kubernetes"),
Ecosystem::Linux => serializer.serialize_str("Linux"),
Ecosystem::Mageia(release) => serializer.serialize_str(&format!("Mageia:{}", release)),
Ecosystem::Maven(repository) => {
let mvn: String = match repository.as_str() {
"https://repo.maven.apache.org/maven2" => "Maven".to_string(),
_ => format!("Maven:{}", repository),
};
serializer.serialize_str(&mvn)
}
Ecosystem::MinimOS => serializer.serialize_str("MinimOS"),
Ecosystem::Npm => serializer.serialize_str("npm"),
Ecosystem::NuGet => serializer.serialize_str("NuGet"),
Ecosystem::OpenEuler => serializer.serialize_str("openEuler"),
Ecosystem::OpenSUSE(None) => serializer.serialize_str("openSUSE"),
Ecosystem::OpenSUSE(Some(release)) => {
serializer.serialize_str(&format!("openSUSE:{}", release))
}
Ecosystem::OssFuzz => serializer.serialize_str("OSS-Fuzz"),
Ecosystem::Packagist => serializer.serialize_str("Packagist"),
Ecosystem::PhotonOS(None) => serializer.serialize_str("Photon OS"),
Ecosystem::PhotonOS(Some(release)) => {
serializer.serialize_str(&format!("Photon OS:{}", release))
}
Ecosystem::Pub => serializer.serialize_str("Pub"),
Ecosystem::PyPI => serializer.serialize_str("PyPI"),
Ecosystem::Python => serializer.serialize_str("Python"),
Ecosystem::RedHat(None) => serializer.serialize_str("Red Hat"),
Ecosystem::RedHat(Some(release)) => {
serializer.serialize_str(&format!("Red Hat:{}", release))
}
Ecosystem::RockyLinux(None) => serializer.serialize_str("Rocky Linux"),
Ecosystem::RockyLinux(Some(release)) => {
serializer.serialize_str(&format!("Rocky Linux:{}", release))
}
Ecosystem::RubyGems => serializer.serialize_str("RubyGems"),
Ecosystem::SUSE(None) => serializer.serialize_str("SUSE"),
Ecosystem::SUSE(Some(release)) => {
serializer.serialize_str(&format!("SUSE:{}", release))
}
Ecosystem::SwiftURL => serializer.serialize_str("SwiftURL"),
Ecosystem::Ubuntu {
version,
pro,
lts,
metadata,
fips,
} => {
let mut parts: Vec<String> = vec!["Ubuntu".to_string()];
if *pro {
parts.push("Pro".to_string());
}
if let Some(stream) = fips {
parts.push(stream.clone());
}
parts.push(version.clone());
if *lts {
parts.push("LTS".to_string());
}
if let Some(meta) = metadata {
parts.push("for".to_string());
parts.push(meta.clone());
}
let serialized = parts.join(":");
serializer.serialize_str(&serialized)
}
Ecosystem::UVI => serializer.serialize_str("UVI"),
Ecosystem::VSCode => serializer.serialize_str("VSCode"),
Ecosystem::Wolfi => serializer.serialize_str("Wolfi"),
}
}
}
impl<'de> Deserialize<'de> for Ecosystem {
fn deserialize<D>(deserializer: D) -> Result<Ecosystem, D::Error>
where
D: Deserializer<'de>,
{
struct EcosystemVisitor;
impl Visitor<'_> for EcosystemVisitor {
type Value = Ecosystem;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a valid string representing an ecosystem")
}
fn visit_str<E>(self, value: &str) -> Result<Ecosystem, E>
where
E: de::Error,
{
match value {
"AlmaLinux" | "AlmaLinux:" => Ok(Ecosystem::AlmaLinux(None)),
"Alpaquita" => Ok(Ecosystem::Alpaquita),
_ if value.starts_with("AlmaLinux:") => Ok(Ecosystem::AlmaLinux(
value.strip_prefix("AlmaLinux:").map(|v| v.to_string()),
)),
"Alpine" => Ok(Ecosystem::Alpine(None)),
_ if value.starts_with("Alpine:") => Ok(Ecosystem::Alpine(
value.strip_prefix("Alpine:").map(|v| v.to_string()),
)),
"Android" => Ok(Ecosystem::Android),
"BellSoft Hardened Containers" => Ok(Ecosystem::BellSoftHardenedContainers),
"Bioconductor" => Ok(Ecosystem::Bioconductor),
"Bitnami" => Ok(Ecosystem::Bitnami),
"Chainguard" => Ok(Ecosystem::Chainguard),
"ConanCenter" => Ok(Ecosystem::ConanCenter),
"CRAN" => Ok(Ecosystem::CRAN),
"crates.io" => Ok(Ecosystem::CratesIO),
"Debian" => Ok(Ecosystem::Debian(None)),
_ if value.starts_with("Debian:") => Ok(Ecosystem::Debian(
value.strip_prefix("Debian:").map(|v| v.to_string()),
)),
"DWF" => Ok(Ecosystem::DWF),
"Echo" => Ok(Ecosystem::Echo),
"GHC" => Ok(Ecosystem::GHC),
"GitHub Actions" => Ok(Ecosystem::GitHubActions),
"Go" => Ok(Ecosystem::Go),
"GSD" => Ok(Ecosystem::GSD),
"Hackage" => Ok(Ecosystem::Hackage),
"Hex" => Ok(Ecosystem::Hex),
"JavaScript" => Ok(Ecosystem::JavaScript),
"Julia" => Ok(Ecosystem::Julia),
"Kubernetes" => Ok(Ecosystem::Kubernetes),
"Linux" => Ok(Ecosystem::Linux),
_ if value.starts_with("Mageia:") => Ok(Ecosystem::Mageia(
value
.strip_prefix("Mageia:")
.map(|v| v.to_string())
.unwrap(),
)),
"Maven" | "Maven:" => Ok(Ecosystem::Maven(
"https://repo.maven.apache.org/maven2".to_string(),
)),
_ if value.starts_with("Maven:") => Ok(Ecosystem::Maven(
value.strip_prefix("Maven:").map(|v| v.to_string()).unwrap(),
)),
"MinimOS" => Ok(Ecosystem::MinimOS),
"npm" => Ok(Ecosystem::Npm),
"NuGet" => Ok(Ecosystem::NuGet),
"openEuler" => Ok(Ecosystem::OpenEuler),
"openSUSE" => Ok(Ecosystem::OpenSUSE(None)),
_ if value.starts_with("openSUSE:") => Ok(Ecosystem::OpenSUSE(
value.strip_prefix("openSUSE:").map(|v| v.to_string()),
)),
"OSS-Fuzz" => Ok(Ecosystem::OssFuzz),
"Packagist" => Ok(Ecosystem::Packagist),
"Photon OS" | "Photon OS:" => Ok(Ecosystem::PhotonOS(None)),
_ if value.starts_with("Photon OS:") => Ok(Ecosystem::PhotonOS(
value.strip_prefix("Photon OS:").map(|v| v.to_string()),
)),
"Pub" => Ok(Ecosystem::Pub),
"PyPI" => Ok(Ecosystem::PyPI),
"Python" => Ok(Ecosystem::Python),
"Red Hat" => Ok(Ecosystem::RedHat(None)),
_ if value.starts_with("Red Hat:") => Ok(Ecosystem::RedHat(
value.strip_prefix("Red Hat:").map(|v| v.to_string()),
)),
"Rocky Linux" | "Rocky Linux:" => Ok(Ecosystem::RockyLinux(None)),
_ if value.starts_with("Rocky Linux:") => Ok(Ecosystem::RockyLinux(
value.strip_prefix("Rocky Linux:").map(|v| v.to_string()),
)),
"RubyGems" => Ok(Ecosystem::RubyGems),
"SUSE" => Ok(Ecosystem::SUSE(None)),
_ if value.starts_with("SUSE:") => Ok(Ecosystem::SUSE(
value.strip_prefix("SUSE:").map(|v| v.to_string()),
)),
"SwiftURL" => Ok(Ecosystem::SwiftURL),
_ if value.starts_with("Ubuntu:") => {
regex_switch!(value,
r#"^Ubuntu(?::Pro)?(?::(?<fips>FIPS(?:-preview|-updates)?))?:(?<version>\d+\.\d+)(?::LTS)?(?::for:(?<specialized>.+))?"# => {
Ecosystem::Ubuntu {
version: version.to_string(),
metadata: (!specialized.is_empty()).then_some(specialized.to_string()),
fips: (!fips.is_empty()).then_some(fips.to_string()),
pro: value.contains(":Pro"),
lts: value.contains(":LTS"),
}
}
).ok_or(de::Error::unknown_variant(value, &["Ecosystem"]))
}
"UVI" => Ok(Ecosystem::UVI),
"VSCode" => Ok(Ecosystem::VSCode),
"Wolfi" => Ok(Ecosystem::Wolfi),
_ => Err(de::Error::unknown_variant(value, &["Ecosystem"])),
}
}
}
deserializer.deserialize_str(EcosystemVisitor)
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum RangeType {
Ecosystem,
Git,
Semver,
Unspecified,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Event {
Introduced(String),
Fixed(String),
#[serde(rename = "last_affected")]
LastAffected(String),
Limit(String),
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Range {
#[serde(rename = "type")]
pub range_type: RangeType,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
pub events: Vec<Event>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Affected {
#[serde(skip_serializing_if = "Option::is_none")]
pub package: Option<Package>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Vec<Severity>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ranges: Option<Vec<Range>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub versions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ecosystem_specific: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_specific: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum ReferenceType {
Advisory,
Article,
Detection,
Discussion,
Evidence,
Fix,
Git,
Introduced,
Package,
Report,
#[default]
#[serde(rename = "NONE")]
Undefined,
Web,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Reference {
#[serde(rename = "type", default)]
pub reference_type: ReferenceType,
pub url: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum SeverityType {
#[serde(rename = "CVSS_V2")]
CVSSv2,
#[serde(rename = "CVSS_V3")]
CVSSv3,
#[serde(rename = "CVSS_V4")]
CVSSv4,
Ubuntu,
#[default]
#[serde(rename = "UNSPECIFIED")]
Unspecified,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Severity {
#[serde(rename = "type", default)]
pub severity_type: SeverityType,
pub score: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[non_exhaustive]
pub enum CreditType {
Analyst,
Coordinator,
Finder,
Other,
RemediationDeveloper,
RemediationReviewer,
RemediationVerifier,
Reporter,
Sponsor,
Tool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Credit {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_type: Option<CreditType>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Vulnerability {
pub schema_version: Option<String>,
pub id: String,
pub published: Option<DateTime<Utc>>,
pub modified: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub withdrawn: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aliases: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
pub affected: Option<Vec<Affected>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub references: Option<Vec<Reference>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Vec<Severity>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credits: Option<Vec<Credit>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_specific: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
fn check_ser_deser<T: Serialize + Deserialize<'static> + std::fmt::Debug + PartialEq>(
deser: T,
ser: &'static str,
) {
assert_eq!(serde_json::to_string(&deser).unwrap(), ser);
assert_eq!(serde_json::from_str::<T>(ser).unwrap(), deser);
}
#[test]
fn test_no_serialize_null_fields() {
let vuln = Vulnerability {
schema_version: Some("1.3.0".to_string()),
id: "OSV-2020-484".to_string(),
published: Some(chrono::Utc::now()),
modified: chrono::Utc::now(),
withdrawn: None,
aliases: None,
upstream: None,
related: None,
summary: None,
details: None,
affected: None,
references: None,
severity: None,
credits: None,
database_specific: None,
};
let as_json = serde_json::json!(vuln);
let str_json = as_json.to_string();
assert!(!str_json.contains("withdrawn"));
assert!(!str_json.contains("aliases"));
assert!(!str_json.contains("related"));
assert!(!str_json.contains("summary"));
assert!(!str_json.contains("details"));
assert!(!str_json.contains("references"));
assert!(!str_json.contains("severity"));
assert!(!str_json.contains("credits"));
assert!(!str_json.contains("database_specific"));
}
#[test]
fn test_maven_ecosystem() {
let maven = Ecosystem::Maven("https://repo.maven.apache.org/maven2".to_string());
let json_str = r#""Maven""#;
check_ser_deser(maven, json_str);
let maven = Ecosystem::Maven("https://repo1.example.com/maven2".to_string());
let json_str = r#""Maven:https://repo1.example.com/maven2""#;
check_ser_deser(maven, json_str);
let json_str = r#""Maven:""#;
let maven: Ecosystem = serde_json::from_str(json_str).unwrap();
assert_eq!(
maven,
Ecosystem::Maven("https://repo.maven.apache.org/maven2".to_string())
);
}
#[test]
fn test_ubuntu_ecosystem() {
let ubuntu = Ecosystem::Ubuntu {
version: "20.04".to_string(),
pro: true,
lts: true,
fips: None,
metadata: None,
};
let json_str = r#""Ubuntu:Pro:20.04:LTS""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "20.04".to_string(),
pro: true,
lts: false,
fips: None,
metadata: None,
};
let json_str = r#""Ubuntu:Pro:20.04""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "20.04".to_string(),
pro: false,
lts: true,
fips: None,
metadata: None,
};
let json_str = r#""Ubuntu:20.04:LTS""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "20.04".to_string(),
pro: false,
lts: false,
fips: None,
metadata: None,
};
let json_str = r#""Ubuntu:20.04""#;
check_ser_deser(ubuntu, json_str);
let json_str = r#""Ubuntu:Pro:24.04:LTS:Realtime:Kernel""#;
let ubuntu: Ecosystem = serde_json::from_str(json_str).unwrap();
assert_eq!(
ubuntu,
Ecosystem::Ubuntu {
version: "24.04".to_string(),
pro: true,
lts: true,
fips: None,
metadata: None,
}
);
let ubuntu = Ecosystem::Ubuntu {
version: "22.04".to_string(),
pro: false,
lts: true,
fips: None,
metadata: Some("NVIDIA:BlueField".to_string()),
};
let json_str = r#""Ubuntu:22.04:LTS:for:NVIDIA:BlueField""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "22.04".to_string(),
pro: true,
lts: true,
fips: Some("FIPS-preview".to_string()),
metadata: None,
};
let json_str = r#""Ubuntu:Pro:FIPS-preview:22.04:LTS""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "18.04".to_string(),
pro: true,
lts: true,
fips: Some("FIPS-updates".to_string()),
metadata: None,
};
let json_str = r#""Ubuntu:Pro:FIPS-updates:18.04:LTS""#;
check_ser_deser(ubuntu, json_str);
let ubuntu = Ecosystem::Ubuntu {
version: "16.04".to_string(),
pro: true,
lts: true,
fips: Some("FIPS".to_string()),
metadata: None,
};
let json_str = r#""Ubuntu:Pro:FIPS:16.04:LTS""#;
check_ser_deser(ubuntu, json_str);
}
}