| //! OSV advisories. |
| //! |
| //! It implements the parts of the [OSV schema](https://ossf.github.io/osv-schema) required for |
| //! RustSec. |
| |
| use tame_index::external::gix; |
| |
| use super::ranges_for_advisory; |
| use crate::advisory::Versions; |
| use crate::{ |
| advisory::{affected::FunctionPath, Affected, Category, Id, Informational}, |
| repository::git::{self, GitModificationTimes, GitPath}, |
| Advisory, |
| }; |
| use serde::{Deserialize, Deserializer, Serialize}; |
| use std::str::FromStr; |
| use url::Url; |
| |
| const ECOSYSTEM: &str = "crates.io"; |
| |
| /// Security advisory in the format defined by <https://github.com/google/osv> |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| #[cfg_attr(docsrs, doc(cfg(feature = "osv-export")))] |
| pub struct OsvAdvisory { |
| schema_version: Option<semver::Version>, |
| id: Id, |
| modified: String, // maybe add an rfc3339 newtype? |
| published: String, // maybe add an rfc3339 newtype? |
| #[serde(skip_serializing_if = "Option::is_none")] |
| withdrawn: Option<String>, // maybe add an rfc3339 newtype? |
| #[serde(default)] |
| aliases: Vec<Id>, |
| #[serde(default)] |
| related: Vec<Id>, |
| summary: String, |
| details: String, |
| #[serde(default)] |
| severity: Vec<OsvSeverity>, |
| #[serde(default)] |
| affected: Vec<OsvAffected>, |
| #[serde(default)] |
| references: Vec<OsvReference>, |
| #[serde(default)] |
| database_specific: MainOsvDatabaseSpecific, |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvPackage { |
| /// Set to a constant identifying crates.io |
| pub(crate) ecosystem: String, |
| /// Crate name |
| pub(crate) name: String, |
| /// https://github.com/package-url/purl-spec derived from the other two |
| #[serde(default)] |
| purl: Option<String>, |
| } |
| |
| impl From<&cargo_lock::Name> for OsvPackage { |
| fn from(package: &cargo_lock::Name) -> Self { |
| OsvPackage { |
| ecosystem: ECOSYSTEM.to_string(), |
| name: package.to_string(), |
| purl: Some("pkg:cargo/".to_string() + package.as_str()), |
| } |
| } |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| #[allow(non_camel_case_types)] |
| #[serde(tag = "type", content = "score")] |
| pub enum OsvSeverity { |
| CVSS_V3(cvss::v3::Base), |
| } |
| |
| impl From<cvss::v3::Base> for OsvSeverity { |
| fn from(cvss: cvss::v3::Base) -> Self { |
| OsvSeverity::CVSS_V3(cvss) |
| } |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvAffected { |
| pub(crate) package: OsvPackage, |
| ecosystem_specific: Option<OsvEcosystemSpecific>, |
| database_specific: OsvDatabaseSpecific, |
| ranges: Option<Vec<OsvJsonRange>>, |
| // FIXME deserialize with deserialize_semver_compat |
| versions: Option<Vec<String>>, |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvJsonRange { |
| // 'type' is a reserved keyword in Rust |
| #[serde(rename = "type")] |
| kind: String, |
| events: Vec<OsvTimelineEvent>, |
| // 'repo' field is not used because we don't track or export git commit data |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub enum OsvTimelineEvent { |
| #[serde(rename = "introduced")] |
| #[serde(deserialize_with = "deserialize_semver_compat")] |
| Introduced(semver::Version), |
| #[serde(rename = "fixed")] |
| #[serde(deserialize_with = "deserialize_semver_compat")] |
| Fixed(semver::Version), |
| #[serde(rename = "last_affected")] |
| #[serde(deserialize_with = "deserialize_semver_compat")] |
| LastAffected(semver::Version), |
| } |
| |
| fn deserialize_semver_compat<'de, D>(deserializer: D) -> Result<semver::Version, D::Error> |
| where |
| D: Deserializer<'de>, |
| { |
| let mut ver = String::deserialize(deserializer)?; |
| match ver.matches('.').count() { |
| 0 => ver.push_str(".0.0"), |
| 1 => ver.push_str(".0"), |
| _ => (), |
| } |
| semver::Version::from_str(&ver).map_err(serde::de::Error::custom) |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvReference { |
| // 'type' is a reserved keyword in Rust |
| #[serde(rename = "type")] |
| pub kind: OsvReferenceKind, |
| pub url: Url, |
| } |
| |
| impl From<Url> for OsvReference { |
| fn from(url: Url) -> Self { |
| OsvReference { |
| kind: guess_url_kind(&url), |
| url, |
| } |
| } |
| } |
| |
| #[allow(clippy::upper_case_acronyms)] |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub enum OsvReferenceKind { |
| ADVISORY, |
| #[allow(dead_code)] |
| ARTICLE, |
| REPORT, |
| #[allow(dead_code)] |
| FIX, |
| PACKAGE, |
| WEB, |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvEcosystemSpecific { |
| affects: Option<OsvEcosystemSpecificAffected>, |
| affected_functions: Option<Vec<FunctionPath>>, |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvEcosystemSpecificAffected { |
| arch: Vec<platforms::target::Arch>, |
| os: Vec<platforms::target::OS>, |
| /// We include function names only in order to allow changing |
| /// the way versions are specified without an API break |
| functions: Vec<FunctionPath>, |
| } |
| |
| impl From<Affected> for OsvEcosystemSpecificAffected { |
| fn from(a: Affected) -> Self { |
| OsvEcosystemSpecificAffected { |
| arch: a.arch, |
| os: a.os, |
| functions: a.functions.into_keys().collect(), |
| } |
| } |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct OsvDatabaseSpecific { |
| #[serde(default)] |
| categories: Vec<Category>, |
| cvss: Option<cvss::v3::Base>, |
| informational: Option<Informational>, |
| } |
| |
| #[derive(Debug, Clone, Serialize, Deserialize, Default)] |
| pub struct MainOsvDatabaseSpecific { |
| #[serde(default)] |
| license: Option<String>, |
| } |
| |
| impl OsvAdvisory { |
| /// Advisory ID |
| pub fn id(&self) -> &Id { |
| &self.id |
| } |
| |
| /// Publication date |
| pub fn published(&self) -> &str { |
| &self.published |
| } |
| |
| /// Converts a single RustSec advisory to OSV format. |
| /// `path` is the path to the advisory file. It must be relative to the git repository root. |
| pub fn from_rustsec( |
| advisory: Advisory, |
| mod_times: &GitModificationTimes, |
| path: GitPath<'_>, |
| ) -> Self { |
| let metadata = advisory.metadata; |
| |
| // Assemble the URLs to put into 'references' field |
| let mut reference_urls: Vec<Url> = Vec::new(); |
| // link to the package on crates.io |
| let package_url = "https://crates.io/crates/".to_owned() + metadata.package.as_str(); |
| reference_urls.push(Url::parse(&package_url).unwrap()); |
| // link to human-readable RustSec advisory |
| let advisory_url = format!( |
| "https://rustsec.org/advisories/{}.html", |
| metadata.id.as_str() |
| ); |
| reference_urls.push(Url::parse(&advisory_url).unwrap()); |
| // primary URL for the issue specified in the advisory |
| if let Some(url) = metadata.url { |
| reference_urls.push(url); |
| } |
| // other references |
| reference_urls.extend(metadata.references); |
| |
| OsvAdvisory { |
| schema_version: None, |
| id: metadata.id, |
| modified: git_time_to_rfc3339(mod_times.for_path(path)), |
| published: rustsec_date_to_rfc3339(&metadata.date), |
| affected: vec![OsvAffected { |
| package: (&metadata.package).into(), |
| ranges: Some(vec![timeline_for_advisory(&advisory.versions)]), |
| versions: Some(vec![]), |
| ecosystem_specific: Some(OsvEcosystemSpecific { |
| affects: Some(advisory.affected.unwrap_or_default().into()), |
| affected_functions: None, |
| }), |
| database_specific: OsvDatabaseSpecific { |
| categories: metadata.categories, |
| cvss: metadata.cvss.clone(), |
| informational: metadata.informational, |
| }, |
| }], |
| withdrawn: metadata.withdrawn.map(|d| rustsec_date_to_rfc3339(&d)), |
| aliases: metadata.aliases, |
| related: metadata.related, |
| summary: metadata.title, |
| severity: metadata.cvss.into_iter().map(|s| s.into()).collect(), |
| details: metadata.description, |
| references: osv_references(reference_urls), |
| database_specific: MainOsvDatabaseSpecific { |
| license: Some(metadata.license.spdx().to_string()), |
| }, |
| } |
| } |
| |
| /// Try to extract RustSec alias id from OSV advisory metadata |
| pub fn rustsec_refs_imported(&self) -> Vec<Id> { |
| let mut refs: Vec<Id> = self |
| .references |
| .iter() |
| .filter(|r| { |
| r.url |
| .as_str() |
| .starts_with("https://rustsec.org/advisories/") |
| }) |
| .map(|r| Id::from_str(&r.url.as_str()[31..48]).expect("Invalid rustsec url")) |
| .collect(); |
| refs.sort(); |
| refs.dedup(); |
| refs |
| } |
| |
| /// Get crates in crates.io ecosystem referenced in this advisory |
| pub fn crates(&self) -> Vec<String> { |
| let mut res: Vec<String> = self |
| .affected |
| .iter() |
| .filter_map(|a| { |
| if a.package.ecosystem == ECOSYSTEM { |
| Some(a.package.name.clone()) |
| } else { |
| None |
| } |
| }) |
| .collect(); |
| res.sort(); |
| res.dedup(); |
| res |
| } |
| |
| /// Get aliases ids |
| pub fn aliases(&self) -> &[Id] { |
| self.aliases.as_slice() |
| } |
| |
| /// Is this advisory withdrawn? |
| pub fn withdrawn(&self) -> bool { |
| self.withdrawn.is_some() |
| } |
| } |
| |
| fn osv_references(references: Vec<Url>) -> Vec<OsvReference> { |
| references.into_iter().map(|u| u.into()).collect() |
| } |
| |
| fn guess_url_kind(url: &Url) -> OsvReferenceKind { |
| let str = url.as_str(); |
| if (str.contains("://github.com/") || str.contains("://gitlab.")) && str.contains("/issues/") { |
| OsvReferenceKind::REPORT |
| // the check for "/advisories/" matches both RustSec and GHSA URLs |
| } else if str.contains("/advisories/") || str.contains("://cve.mitre.org/") { |
| OsvReferenceKind::ADVISORY |
| } else if str.contains("://crates.io/crates/") { |
| OsvReferenceKind::PACKAGE |
| } else { |
| OsvReferenceKind::WEB |
| } |
| } |
| |
| /// Generates the timeline of the bug being introduced and fixed for the |
| /// [`affected[].ranges[].events`](https://github.com/ossf/osv-schema/blob/main/schema.md#affectedrangesevents-fields) field. |
| fn timeline_for_advisory(versions: &Versions) -> OsvJsonRange { |
| let ranges = ranges_for_advisory(versions); |
| assert!(!ranges.is_empty()); // zero ranges means nothing is affected, so why even have an advisory? |
| let mut timeline = Vec::new(); |
| for range in ranges { |
| match range.introduced { |
| Some(ver) => timeline.push(OsvTimelineEvent::Introduced(ver)), |
| None => timeline.push(OsvTimelineEvent::Introduced( |
| semver::Version::parse("0.0.0-0").unwrap(), |
| )), |
| } |
| #[allow(clippy::single_match)] |
| match range.fixed { |
| Some(ver) => timeline.push(OsvTimelineEvent::Fixed(ver)), |
| None => (), // "everything after 'introduced' is affected" is implicit in OSV |
| } |
| } |
| OsvJsonRange { |
| kind: "SEMVER".to_string(), |
| events: timeline, |
| } |
| } |
| |
| fn git_time_to_rfc3339(time: gix::date::Time) -> String { |
| git::gix_time_to_time(time) |
| .to_offset(time::UtcOffset::UTC) |
| .format(&time::format_description::well_known::Rfc3339) |
| .expect("well-known format to heap never fails") |
| } |
| |
| fn rustsec_date_to_rfc3339(d: &crate::advisory::Date) -> String { |
| format!("{}-{:02}-{:02}T12:00:00Z", d.year(), d.month(), d.day()) |
| } |