diff --git a/Cargo.lock b/Cargo.lock index c110546e6..f08c16547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8594,6 +8594,7 @@ dependencies = [ "humantime", "humantime-serde", "json-merge-patch", + "liblzma", "log", "num-traits", "oci-client", diff --git a/Cargo.toml b/Cargo.toml index 47c750262..b7e5e758f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,7 @@ sea-query = "0.32.0" # keep aligned with sea-orm semver = "1" serde = "1.0.183" serde-cyclonedx = "0.10.0" -serde_json = "1.0.114" +serde_json = { version = "1.0.114", features = ["raw_value"] } serde_qs = { version = "1", features = ["actix4"] } serde_yml = { package = "serde_yaml_ng", version = "0.10" } sha2 = "0.11.0" diff --git a/common/src/cpe.rs b/common/src/cpe.rs index ddacc0b1b..90e5bae64 100644 --- a/common/src/cpe.rs +++ b/common/src/cpe.rs @@ -310,6 +310,85 @@ impl Cpe { pub fn language(&self) -> Language { self.uri.language().clone().into() } + + pub fn sw_edition(&self) -> Component { + self.uri.sw_edition().into() + } + + pub fn target_sw(&self) -> Component { + self.uri.target_sw().into() + } + + pub fn target_hw(&self) -> Component { + self.uri.target_hw().into() + } + + pub fn other(&self) -> Component { + self.uri.other().into() + } + + /// Return a copy of this CPE with the version component normalized to ANY. + /// + /// Useful for deriving a vendor/product identity key when the concrete + /// version is carried separately (e.g. via a `version_range`). + /// + /// Known limitation: the extended attributes packed into the URI edition + /// component (`sw_edition`/`target_sw`/`target_hw`/`other`) are not + /// exposed by this wrapper's accessors and are therefore dropped by this + /// normalization. In practice CVE `affected[].cpes` entries are plain + /// `part:vendor:product:version` tuples, so this does not affect the + /// CVE-loader use case this method was added for. + pub fn with_any_version(&self) -> Self { + fn field(component: &Component) -> String { + match component { + Component::Any => String::new(), + Component::NotApplicable => "-".to_string(), + Component::Value(value) => encode_uri_component(value), + } + } + + let part = match self.part().as_ref() { + "*" => String::new(), + other => other.to_string(), + }; + let vendor = field(&self.vendor()); + let product = field(&self.product()); + let update = field(&self.update()); + let edition = field(&self.edition()); + let language = match self.language() { + Language::Any => String::new(), + Language::Language(value) => value, + }; + + let mut components = vec![ + part, + vendor, + product, + String::new(), + update, + edition, + language, + ]; + // trailing empty (ANY) components must be dropped: the URI parser + // treats empty components in the middle as ANY, but rejects a + // trailing empty language component. + while components.last().is_some_and(|c| c.is_empty()) { + components.pop(); + } + + // Reconstructed from an already-valid CPE with only the version blanked, + // so re-parsing cannot fail; keep the original on the unreachable error + // path rather than propagate an impossible failure through the signature. + match OwnedUri::from_str(&format!("cpe:/{}", components.join(":"))) { + Ok(uri) => Self { uri }, + Err(err) => { + log::warn!( + "failed to normalize CPE {self:?} to any-version, keeping original: {err}" + ); + self.clone() + } + } + } } impl Debug for Cpe { @@ -332,22 +411,144 @@ impl From for Cpe { } } +/// Split a CPE 2.3 formatted string body into its components, honoring `\`-escapes. +fn split_cpe23(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '\\' => { + cur.push(c); + if let Some(next) = chars.next() { + cur.push(next); + } + } + ':' => parts.push(std::mem::take(&mut cur)), + _ => cur.push(c), + } + } + parts.push(cur); + parts +} + +/// Remove `\`-escapes from a CPE 2.3 formatted string component. +fn unescape_cpe23(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else { + out.push(c); + } + } + out +} + +/// Percent-encode a decoded component value using CPE 2.2 URI syntax, +/// mapping the `?`/`*` wildcards to their `%01`/`%02` special encodings. +fn encode_uri_component(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '?' => out.push_str("%01"), + '*' => out.push_str("%02"), + c if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') => out.push(c), + c => { + let mut buf = [0u8; 4]; + for b in c.encode_utf8(&mut buf).as_bytes() { + out.push_str(&format!("%{b:02x}")); + } + } + } + } + out +} + +/// Turn a raw (still escaped) CPE 2.3 component into its CPE 2.2 URI form. +fn cpe23_component_to_uri(raw: &str) -> String { + match raw { + // ANY is the empty component in URI syntax + "*" => String::new(), + "-" => "-".to_string(), + _ => encode_uri_component(&unescape_cpe23(raw)), + } +} + +/// Convert a CPE 2.3 formatted string (`cpe:2.3:part:vendor:...`, 11 attribute +/// components) into a CPE 2.2 URI, packing the extended attributes +/// (sw_edition, target_sw, target_hw, other) into the edition component. +fn cpe23_to_uri(value: &str, body: &str) -> Result { + let invalid = || cpe::error::CpeError::InvalidUri { + value: value.to_owned(), + }; + + let raw = split_cpe23(body); + if raw.len() != 11 { + return Err(invalid()); + } + + let part = match raw[0].as_str() { + "*" | "-" => "", + part => part, + }; + + let [vendor, product, version, update, edition] = + [&raw[1], &raw[2], &raw[3], &raw[4], &raw[5]].map(|c| cpe23_component_to_uri(c)); + let [sw_edition, target_sw, target_hw, other] = + [&raw[7], &raw[8], &raw[9], &raw[10]].map(|c| cpe23_component_to_uri(c)); + + let edition = if [&sw_edition, &target_sw, &target_hw, &other] + .iter() + .all(|c| c.is_empty()) + { + edition + } else { + format!("~{edition}~{sw_edition}~{target_sw}~{target_hw}~{other}") + }; + + let language = match raw[6].as_str() { + "*" | "-" => String::new(), + lang => unescape_cpe23(lang), + }; + + let mut components = vec![ + part.to_string(), + vendor, + product, + version, + update, + edition, + language, + ]; + // the URI parser treats empty components in the middle as ANY, but fails on + // an empty trailing language component + while components.last().is_some_and(|c| c.is_empty()) { + components.pop(); + } + + OwnedUri::from_str(&format!("cpe:/{}", components.join(":"))) +} + impl FromStr for Cpe { type Err = ::Err; fn from_str(s: &str) -> Result { - Ok(Self { - uri: OwnedUri::from_str(s)?, - }) + let uri = match s.strip_prefix("cpe:2.3:") { + Some(body) => cpe23_to_uri(s, body)?, + None => OwnedUri::from_str(s)?, + }; + Ok(Self { uri }) } } impl TryFrom<&str> for Cpe { type Error = ::Err; fn try_from(value: &str) -> Result { - Ok(Self { - uri: OwnedUri::from_str(value)?, - }) + Self::from_str(value) } } @@ -572,4 +773,133 @@ mod test { "61bca16a-febc-5d79-8b4d-f51fa37c876d" ); } + + #[test] + fn cpe23_simple() { + let cpe = + Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*").expect("must parse"); + assert!(matches!(cpe.part(), CpeType::Application)); + assert_eq!(cpe.vendor().as_ref(), "openssl"); + assert_eq!(cpe.product().as_ref(), "openssl"); + assert_eq!(cpe.version().as_ref(), "0.9.8w"); + assert!(matches!(cpe.update(), Component::Any)); + assert!(matches!(cpe.edition(), Component::Any)); + assert!(matches!(cpe.language(), Language::Any)); + } + + #[test] + fn cpe23_same_uuid_as_cpe22() { + let cpe23 = Cpe::from_str("cpe:2.3:a:redhat:enterprise_linux:9:*:crb:*:*:*:*:*") + .expect("must parse"); + assert_eq!( + cpe23.uuid().to_string(), + "61bca16a-febc-5d79-8b4d-f51fa37c876d" + ); + } + + #[test] + fn cpe23_not_applicable() { + let cpe = Cpe::from_str("cpe:2.3:a:busybox:busybox:-:*:*:*:*:*:*:*").expect("must parse"); + assert!(matches!(cpe.version(), Component::NotApplicable)); + } + + #[test] + fn cpe23_escaped_characters() { + let cpe = Cpe::from_str(r"cpe:2.3:a:foo\:bar:some\+product:1.0:*:*:*:*:*:*:*") + .expect("must parse"); + assert_eq!(cpe.vendor().as_ref(), "foo:bar"); + assert_eq!(cpe.product().as_ref(), "some+product"); + assert_eq!(cpe.version().as_ref(), "1.0"); + } + + #[test] + fn cpe23_embedded_wildcard() { + let cpe = + Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8*:*:*:*:*:*:*:*").expect("must parse"); + assert_eq!(cpe.version().as_ref(), "0.9.8*"); + } + + #[test] + fn cpe23_extended_attributes() { + let cpe = Cpe::from_str("cpe:2.3:o:microsoft:windows_10:1607:*:*:*:*:*:x64:*") + .expect("must parse"); + assert!(matches!(cpe.part(), CpeType::OperatingSystem)); + assert_eq!(cpe.vendor().as_ref(), "microsoft"); + assert_eq!(cpe.product().as_ref(), "windows_10"); + assert_eq!(cpe.version().as_ref(), "1607"); + // extended attributes end up in the packed edition component of the URI + assert_eq!( + cpe.to_string(), + "cpe:/o:microsoft:windows_10:1607:*~*~*~*~x64~*:*" + ); + } + + #[test] + fn cpe23_language() { + let cpe = + Cpe::from_str("cpe:2.3:a:vendor:product:1.0:*:*:en-us:*:*:*:*").expect("must parse"); + assert_eq!(cpe.language().as_ref(), "en-US"); + } + + #[test] + fn cpe23_wrong_component_count() { + assert!(Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w").is_err()); + assert!(Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*:*").is_err()); + } + + #[test] + fn cpe23_garbage_rejected() { + // unescapable garbage (spaces are not valid in any CPE component) + assert!( + Cpe::from_str("cpe:2.3:a:libfoo.a(bar.o): in function `baz':1.0:-:*:*:*:*:*:*:*") + .is_err() + ); + } + + #[test] + fn with_any_version_normalizes_concrete_version() { + let cpe = + Cpe::from_str("cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*").expect("must parse"); + let normalized = cpe.with_any_version(); + + assert!(matches!(normalized.part(), CpeType::Application)); + assert_eq!(normalized.vendor().as_ref(), "openssl"); + assert_eq!(normalized.product().as_ref(), "openssl"); + assert!(matches!(normalized.version(), Component::Any)); + + // vendor/product identity is unchanged, only the version differs + assert_ne!(cpe.uuid(), normalized.uuid()); + } + + #[test] + fn with_any_version_is_idempotent_for_already_any_version() { + let cpe = Cpe::from_str("cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*").expect("must parse"); + let normalized = cpe.with_any_version(); + assert_eq!(cpe.uuid(), normalized.uuid()); + } + + #[test] + fn with_any_version_preserves_special_characters() { + let cpe = Cpe::from_str(r"cpe:2.3:a:foo\:bar:some\+product:1.0:*:*:*:*:*:*:*") + .expect("must parse"); + let normalized = cpe.with_any_version(); + assert_eq!(normalized.vendor().as_ref(), "foo:bar"); + assert_eq!(normalized.product().as_ref(), "some+product"); + assert!(matches!(normalized.version(), Component::Any)); + } + + #[test] + fn with_any_version_drops_unexposed_extended_attributes() { + // known limitation: sw_edition/target_sw/target_hw/other are packed + // into the URI edition component and aren't reachable through this + // wrapper's accessors, so with_any_version can't round-trip them. + // CVE affected[].cpes entries never carry these, so this doesn't + // affect the CVE-loader use case. + let cpe = Cpe::from_str("cpe:2.3:o:microsoft:windows_10:1607:*:*:*:*:*:x64:*") + .expect("must parse"); + let normalized = cpe.with_any_version(); + assert!(matches!(normalized.version(), Component::Any)); + assert_eq!(normalized.vendor().as_ref(), "microsoft"); + assert_eq!(normalized.product().as_ref(), "windows_10"); + } } diff --git a/entity/src/cpe.rs b/entity/src/cpe.rs index be8590761..f0841bfbb 100644 --- a/entity/src/cpe.rs +++ b/entity/src/cpe.rs @@ -16,6 +16,10 @@ pub struct Model { pub update: Option, pub edition: Option, pub language: Option, + pub sw_edition: Option, + pub target_sw: Option, + pub target_hw: Option, + pub other: Option, } impl_try_into_cpe!(Model); @@ -64,6 +68,16 @@ impl ActiveModelBehavior for ActiveModel {} impl Display for Model { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + // re-pack the extended 2.3 attributes; `set_edition` unpacks them again. + // Bound before the builder so it outlives the borrow. + let edition = pack_edition( + &self.edition, + &self.sw_edition, + &self.target_sw, + &self.target_hw, + &self.other, + ); + let mut cpe = cpe::uri::Uri::new(); self.part.as_ref().map(|part| cpe.set_part(part)); self.vendor.as_ref().map(|vendor| cpe.set_vendor(vendor)); @@ -74,9 +88,7 @@ impl Display for Model { .as_ref() .map(|version| cpe.set_version(version)); self.update.as_ref().map(|update| cpe.set_update(update)); - self.edition - .as_ref() - .map(|edition| cpe.set_edition(edition)); + edition.as_ref().map(|edition| cpe.set_edition(edition)); self.language .as_ref() .map(|language| cpe.set_language(language)); @@ -122,6 +134,26 @@ impl ActiveModel { Language::Any => Set(Some("*".to_string())), Language::Language(inner) => Set(Some(inner)), }, + sw_edition: match cpe.sw_edition() { + Component::Any => Set(Some("*".to_string())), + Component::NotApplicable => Set(None), + Component::Value(inner) => Set(Some(inner)), + }, + target_sw: match cpe.target_sw() { + Component::Any => Set(Some("*".to_string())), + Component::NotApplicable => Set(None), + Component::Value(inner) => Set(Some(inner)), + }, + target_hw: match cpe.target_hw() { + Component::Any => Set(Some("*".to_string())), + Component::NotApplicable => Set(None), + Component::Value(inner) => Set(Some(inner)), + }, + other: match cpe.other() { + Component::Any => Set(Some("*".to_string())), + Component::NotApplicable => Set(None), + Component::Value(inner) => Set(Some(inner)), + }, } } } @@ -142,6 +174,10 @@ pub struct CpeDto { pub update: Option, pub edition: Option, pub language: Option, + pub sw_edition: Option, + pub target_sw: Option, + pub target_hw: Option, + pub other: Option, } impl From for CpeDto { @@ -157,6 +193,10 @@ impl From for CpeDto { update, edition, language, + sw_edition, + target_sw, + target_hw, + other, } = value; Self { @@ -167,6 +207,10 @@ impl From for CpeDto { update, edition, language, + sw_edition, + target_sw, + target_hw, + other, } } } @@ -184,6 +228,10 @@ impl From for (Uuid, CpeDto) { update, edition, language, + sw_edition, + target_sw, + target_hw, + other, } = value; ( @@ -196,6 +244,10 @@ impl From for (Uuid, CpeDto) { update, edition, language, + sw_edition, + target_sw, + target_hw, + other, }, ) } @@ -235,10 +287,25 @@ impl TryFrom for Cpe { type Error = CpeError; fn try_from(value: CpeDto) -> Result { + // Pack the edition together with the extended 2.3 attributes; the URI + // builder unpacks a `~`-delimited edition again on `validate`. Bound + // before the builder so it outlives the borrow. + let edition = pack_edition( + &value.edition, + &value.sw_edition, + &value.target_sw, + &value.target_hw, + &value.other, + ); + let mut cpe = Uri::builder(); apply!(cpe, value => part); - apply_fix!(cpe, value => vendor, product, version, update, edition); + apply_fix!(cpe, value => vendor, product, version, update); + + if let Some(edition) = &edition { + cpe.edition(edition); + } // apply the fix for the language field @@ -254,6 +321,48 @@ impl TryFrom for Cpe { } } +/// Pack the CPE edition together with the four extended 2.3 attributes into a +/// single URI edition component. +/// +/// `"*"` and absent values are treated as ANY (empty in URI syntax). When every +/// extended attribute is ANY the plain edition is returned (or `None` when it is +/// ANY too); otherwise the `~edition~sw_edition~target_sw~target_hw~other` packed +/// form is produced, which the URI parser unpacks back into the attributes. +fn pack_edition( + edition: &Option, + sw_edition: &Option, + target_sw: &Option, + target_hw: &Option, + other: &Option, +) -> Option { + let value = |v: &Option| match v.as_deref() { + None | Some("*") => None, + Some(inner) => Some(inner.to_string()), + }; + + let edition = value(edition); + let sw_edition = value(sw_edition); + let target_sw = value(target_sw); + let target_hw = value(target_hw); + let other = value(other); + + if sw_edition.is_none() && target_sw.is_none() && target_hw.is_none() && other.is_none() { + return edition; + } + + fn part(v: &Option) -> &str { + v.as_deref().unwrap_or_default() + } + Some(format!( + "~{}~{}~{}~{}~{}", + part(&edition), + part(&sw_edition), + part(&target_sw), + part(&target_hw), + part(&other), + )) +} + #[cfg(test)] mod test { use super::*; @@ -307,4 +416,28 @@ mod test { assert_eq!(id, cpe.uuid()); } + + /// The extended 2.3 attributes (here `target_hw`) must survive the + /// model/DTO round-trip via the packed edition component. + #[test] + fn roundtrip_extended_attributes() { + let cpe = Cpe::from_str("cpe:2.3:o:microsoft:windows_10:1607:*:*:en-US:*:*:x64:*") + .expect("must parse"); + + let model: Model = ActiveModel::from_cpe(cpe) + .try_into_model() + .expect("must build model"); + + // the extended attribute lands in its own column + assert_eq!(model.target_hw, Some("x64".to_string())); + assert_eq!(model.sw_edition, Some("*".to_string())); + + // and re-packs into the edition on the way back out + let dto: CpeDto = model.into(); + let cpe: Cpe = dto.try_into().expect("must be able to build cpe"); + assert_eq!( + cpe.to_string(), + "cpe:/o:microsoft:windows_10:1607:*~*~*~*~x64~*:en-US" + ); + } } diff --git a/entity/src/cpe_status.rs b/entity/src/cpe_status.rs new file mode 100644 index 000000000..2756a6d22 --- /dev/null +++ b/entity/src/cpe_status.rs @@ -0,0 +1,97 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "cpe_status")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub advisory_id: Uuid, + pub vulnerability_id: String, + pub status_id: Uuid, + pub cpe_id: Uuid, + pub version_range_id: Uuid, + pub context_cpe_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(belongs_to = "super::version_range::Entity" + from = "Column::VersionRangeId", + to = "super::version_range::Column::Id" + )] + VersionRange, + + #[sea_orm(belongs_to = "super::cpe::Entity", + from = "Column::CpeId" + to = "super::cpe::Column::Id" + )] + Cpe, + + #[sea_orm(belongs_to = "super::vulnerability::Entity", + from = "Column::VulnerabilityId" + to = "super::vulnerability::Column::Id" + )] + Vulnerability, + + #[sea_orm(belongs_to = "super::advisory::Entity", + from = "Column::AdvisoryId" + to = "super::advisory::Column::Id" + )] + Advisory, + + #[sea_orm(belongs_to = "super::status::Entity", + from = "Column::StatusId" + to = "super::status::Column::Id" + )] + Status, + + #[sea_orm(belongs_to = "super::advisory_vulnerability::Entity", + from = "(Column::AdvisoryId, Column::VulnerabilityId)" + to = "(super::advisory_vulnerability::Column::AdvisoryId, super::advisory_vulnerability::Column::VulnerabilityId)" + )] + AdvisoryVulnerability, + + #[sea_orm(belongs_to = "super::cpe::Entity", + from = "Column::ContextCpeId" + to = "super::cpe::Column::Id" + )] + ContextCpe, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::VersionRange.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Cpe.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Vulnerability.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Advisory.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Status.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::AdvisoryVulnerability.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/entity/src/lib.rs b/entity/src/lib.rs index eb0cdad58..a0db02a05 100644 --- a/entity/src/lib.rs +++ b/entity/src/lib.rs @@ -3,6 +3,7 @@ pub mod advisory_vulnerability; pub mod advisory_vulnerability_score; pub mod base_purl; pub mod cpe; +pub mod cpe_status; pub mod expanded_license; pub mod importer; pub mod importer_report; diff --git a/etc/test-data/cve/CVE-2099-0001.json b/etc/test-data/cve/CVE-2099-0001.json new file mode 100644 index 000000000..080d3e72c --- /dev/null +++ b/etc/test-data/cve/CVE-2099-0001.json @@ -0,0 +1,95 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.1", + "cveMetadata": { + "cveId": "CVE-2099-0001", + "assignerOrgId": "00000000-0000-0000-0000-000000000000", + "state": "PUBLISHED", + "assignerShortName": "synthetic-test", + "dateReserved": "2099-01-01T00:00:00.000Z", + "datePublished": "2099-01-02T00:00:00.000Z", + "dateUpdated": "2099-01-03T00:00:00.000Z" + }, + "containers": { + "cna": { + "affected": [ + { + "vendor": "openssl", + "product": "openssl", + "cpes": [ + "cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*" + ], + "defaultStatus": "unknown", + "versions": [ + { + "version": "0.9.8w", + "status": "affected", + "versionType": "custom" + }, + { + "version": "1.0.0", + "lessThan": "1.0.2", + "status": "affected", + "versionType": "semver" + } + ] + }, + { + "vendor": "busybox", + "product": "busybox", + "cpes": [ + "cpe:2.3:a:busybox:busybox:1.19.4:*:*:*:*:*:*:*" + ], + "defaultStatus": "affected" + } + ], + "descriptions": [ + { + "lang": "en", + "value": "SYNTHETIC TEST RECORD (not a real CVE): invented vulnerability used to exercise CPE-keyed status ingestion in trustify's CVE loader test suite. Do not treat as real vulnerability data." + } + ], + "metrics": [ + { + "cvssV3_1": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL", + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "HIGH", + "availabilityImpact": "HIGH" + } + } + ], + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "description": "CWE-000 Synthetic test weakness", + "cweId": "CWE-000", + "type": "CWE" + } + ] + } + ], + "providerMetadata": { + "orgId": "00000000-0000-0000-0000-000000000000", + "shortName": "synthetic-test", + "dateUpdated": "2099-01-03T00:00:00.000Z" + }, + "references": [ + { + "url": "https://example.invalid/synthetic/CVE-2099-0001" + } + ], + "title": "Synthetic test record for CPE-keyed status ingestion" + } + } +} diff --git a/etc/test-data/cve/CVE-2099-0002.json b/etc/test-data/cve/CVE-2099-0002.json new file mode 100644 index 000000000..21875810a --- /dev/null +++ b/etc/test-data/cve/CVE-2099-0002.json @@ -0,0 +1,75 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.1", + "cveMetadata": { + "cveId": "CVE-2099-0002", + "assignerOrgId": "00000000-0000-0000-0000-000000000000", + "state": "PUBLISHED", + "assignerShortName": "synthetic-test", + "dateReserved": "2099-02-01T00:00:00.000Z", + "datePublished": "2099-02-02T00:00:00.000Z", + "dateUpdated": "2099-02-03T00:00:00.000Z" + }, + "containers": { + "cna": { + "affected": [], + "descriptions": [ + { + "lang": "en", + "value": "SYNTHETIC TEST RECORD (not a real CVE): invented vulnerability used to exercise ADP-container CPE ingestion in trustify's CVE loader test suite. The CNA did not annotate CPEs; the ADP (vulnrichment-style) container supplies them, as commonly happens for older CVEs. Do not treat as real vulnerability data." + } + ], + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "description": "CWE-000 Synthetic test weakness", + "cweId": "CWE-000", + "type": "CWE" + } + ] + } + ], + "providerMetadata": { + "orgId": "00000000-0000-0000-0000-000000000000", + "shortName": "synthetic-test", + "dateUpdated": "2099-02-03T00:00:00.000Z" + }, + "references": [ + { + "url": "https://example.invalid/synthetic/CVE-2099-0002" + } + ], + "title": "Synthetic test record for ADP-container CPE ingestion" + }, + "adp": [ + { + "providerMetadata": { + "orgId": "11111111-1111-1111-1111-111111111111", + "shortName": "synthetic-adp", + "dateUpdated": "2099-02-03T00:00:00.000Z" + }, + "title": "Synthetic vulnrichment-style enrichment", + "affected": [ + { + "vendor": "denx", + "product": "u-boot", + "cpes": [ + "cpe:2.3:a:denx:u-boot:*:*:*:*:*:*:*:*" + ], + "defaultStatus": "unknown", + "versions": [ + { + "version": "2019.04", + "lessThan": "2020.10", + "status": "affected", + "versionType": "semver" + } + ] + } + ] + } + ] + } +} diff --git a/etc/test-data/cve/CVE-2099-0003.json b/etc/test-data/cve/CVE-2099-0003.json new file mode 100644 index 000000000..2fc932348 --- /dev/null +++ b/etc/test-data/cve/CVE-2099-0003.json @@ -0,0 +1,64 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.1", + "cveMetadata": { + "cveId": "CVE-2099-0003", + "assignerOrgId": "00000000-0000-0000-0000-000000000000", + "state": "PUBLISHED", + "assignerShortName": "synthetic-test", + "dateReserved": "2099-03-01T00:00:00.000Z", + "datePublished": "2099-03-02T00:00:00.000Z", + "dateUpdated": "2099-03-03T00:00:00.000Z" + }, + "containers": { + "cna": { + "affected": [ + { + "vendor": "openssl", + "product": "openssl", + "cpes": [ + "cpe:2.3:a:openssl:openssl:*:*:*:*:*:*:*:*" + ], + "defaultStatus": "unknown", + "versions": [ + { + "version": "2.0.0", + "lessThan": "3.0.0", + "status": "affected", + "versionType": "semver" + } + ] + } + ], + "descriptions": [ + { + "lang": "en", + "value": "SYNTHETIC TEST RECORD (not a real CVE): invented vulnerability affecting a version range of openssl that deliberately does NOT include 0.9.8w, used to prove the CPE-keyed matching path does not produce false positives across unrelated version ranges. Do not treat as real vulnerability data." + } + ], + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "description": "CWE-000 Synthetic test weakness", + "cweId": "CWE-000", + "type": "CWE" + } + ] + } + ], + "providerMetadata": { + "orgId": "00000000-0000-0000-0000-000000000000", + "shortName": "synthetic-test", + "dateUpdated": "2099-03-03T00:00:00.000Z" + }, + "references": [ + { + "url": "https://example.invalid/synthetic/CVE-2099-0003" + } + ], + "title": "Synthetic test record: openssl version range excluding 0.9.8w" + } + } +} diff --git a/etc/test-data/nvd/CVE-2099-1000.json b/etc/test-data/nvd/CVE-2099-1000.json new file mode 100644 index 000000000..0ed5b541e --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-1000.json @@ -0,0 +1,64 @@ +{ + "id": "CVE-2099-1000", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-01-02T10:00:00.000", + "lastModified": "2099-01-05T12:30:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { + "lang": "en", + "value": "A synthetic test vulnerability in Example Widget affecting a version range." + }, + { + "lang": "es", + "value": "Una vulnerabilidad sintetica de prueba." + } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "description": [ + { "lang": "en", "value": "CWE-79" } + ] + } + ], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:example:widget:*:*:*:*:*:*:*:*", + "versionStartIncluding": "1.0.0", + "versionEndExcluding": "2.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000000001" + }, + { + "vulnerable": false, + "criteria": "cpe:2.3:o:example:not-affected-os:*:*:*:*:*:*:*:*", + "matchCriteriaId": "00000000-0000-0000-0000-000000000002" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2000.json b/etc/test-data/nvd/CVE-2099-2000.json new file mode 100644 index 000000000..a1d5507b1 --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2000.json @@ -0,0 +1,44 @@ +{ + "id": "CVE-2099-2000", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-01T00:00:00.000", + "lastModified": "2099-02-01T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected in the 1.0.0..2.0.0 range (includes 1.19.4)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionStartIncluding": "1.0.0", + "versionEndExcluding": "2.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000002000" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2001.json b/etc/test-data/nvd/CVE-2099-2001.json new file mode 100644 index 000000000..36d520628 --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2001.json @@ -0,0 +1,44 @@ +{ + "id": "CVE-2099-2001", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-02T00:00:00.000", + "lastModified": "2099-02-02T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected only in the 2.0.0..3.0.0 range (excludes 1.19.4)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionStartIncluding": "2.0.0", + "versionEndExcluding": "3.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000002001" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2002.json b/etc/test-data/nvd/CVE-2099-2002.json new file mode 100644 index 000000000..9169f1515 --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2002.json @@ -0,0 +1,43 @@ +{ + "id": "CVE-2099-2002", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-03T00:00:00.000", + "lastModified": "2099-02-03T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected from 1.0.0 onward with no upper bound (includes 1.19.4)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionStartIncluding": "1.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000002002" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2003.json b/etc/test-data/nvd/CVE-2099-2003.json new file mode 100644 index 000000000..870ad82fd --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2003.json @@ -0,0 +1,43 @@ +{ + "id": "CVE-2099-2003", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-04T00:00:00.000", + "lastModified": "2099-02-04T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected below 2.0.0 with no lower bound (includes 1.19.4)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionEndExcluding": "2.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000002003" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2004.json b/etc/test-data/nvd/CVE-2099-2004.json new file mode 100644 index 000000000..235dd6a6b --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2004.json @@ -0,0 +1,44 @@ +{ + "id": "CVE-2099-2004", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-05T00:00:00.000", + "lastModified": "2099-02-05T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected from 1.19.4 inclusive; the component sits exactly on the inclusive lower bound (hit)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionStartIncluding": "1.19.4", + "versionEndExcluding": "2.0.0", + "matchCriteriaId": "00000000-0000-0000-0000-000000002004" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/nvd/CVE-2099-2005.json b/etc/test-data/nvd/CVE-2099-2005.json new file mode 100644 index 000000000..946e150d5 --- /dev/null +++ b/etc/test-data/nvd/CVE-2099-2005.json @@ -0,0 +1,44 @@ +{ + "id": "CVE-2099-2005", + "sourceIdentifier": "nvd@nist.gov", + "published": "2099-02-06T00:00:00.000", + "lastModified": "2099-02-06T00:00:00.000", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "Synthetic NVD advisory: busybox affected below 1.19.4 exclusive; the component sits exactly on the exclusive upper bound (miss)." } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { + "version": "3.1", + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "baseScore": 9.8, + "baseSeverity": "CRITICAL" + } + } + ] + }, + "weaknesses": [], + "configurations": [ + { + "nodes": [ + { + "operator": "OR", + "negate": false, + "cpeMatch": [ + { + "vulnerable": true, + "criteria": "cpe:2.3:a:busybox:busybox:*:*:*:*:*:*:*:*", + "versionStartIncluding": "1.0.0", + "versionEndExcluding": "1.19.4", + "matchCriteriaId": "00000000-0000-0000-0000-000000002005" + } + ] + } + ] + } + ] +} diff --git a/etc/test-data/spdx/cpe23-firmware.json b/etc/test-data/spdx/cpe23-firmware.json new file mode 100644 index 000000000..5ca2c5579 --- /dev/null +++ b/etc/test-data/spdx/cpe23-firmware.json @@ -0,0 +1,115 @@ +{ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": "cpe23-firmware", + "documentNamespace": "http://spdx.org/spdxdocs/cpe23-firmware-6b1e6f36-9a24-4a5c-9c4e-0d7c1a2b3c4d", + "creationInfo": { + "creators": [ + "Tool: example-firmware-sbom-tool" + ], + "created": "2023-01-01T00:00:00Z" + }, + "packages": [ + { + "name": "firmware", + "SPDXID": "SPDXRef-Package-Main", + "versionInfo": "1.0", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false + }, + { + "name": "OpenSSL", + "SPDXID": "SPDXRef-Package-OpenSSL", + "versionInfo": "0.9.8w", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/OpenSSL@0.9.8w" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:openssl:openssl:0.9.8w:*:*:*:*:*:*:*" + } + ] + }, + { + "name": "BusyBox", + "SPDXID": "SPDXRef-Package-BusyBox", + "versionInfo": "1.19.4", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:generic/BusyBox@1.19.4" + }, + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:busybox:busybox:1.19.4:*:*:*:*:*:*:*" + } + ] + }, + { + "name": "Windows", + "SPDXID": "SPDXRef-Package-Windows", + "versionInfo": "1607", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:o:microsoft:windows_10:1607:*:*:en-US:*:*:x64:*" + } + ] + }, + { + "name": "libfoo.a(bar.o): in function `baz':", + "SPDXID": "SPDXRef-Package-Garbage", + "versionInfo": "2.0.0", + "downloadLocation": "NOASSERTION", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:a:libfoo.a(bar.o): in function `baz':2.0.0:*:*:*:*:*:*:*" + } + ] + } + ], + "relationships": [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": "SPDXRef-Package-Main" + }, + { + "spdxElementId": "SPDXRef-Package-Main", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-OpenSSL" + }, + { + "spdxElementId": "SPDXRef-Package-Main", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-BusyBox" + }, + { + "spdxElementId": "SPDXRef-Package-Main", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-Garbage" + }, + { + "spdxElementId": "SPDXRef-Package-Main", + "relationshipType": "CONTAINS", + "relatedSpdxElement": "SPDXRef-Package-Windows" + } + ] +} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 20db34bc0..194995ac8 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -66,6 +66,8 @@ mod m0002210_sbom_node_name_index; mod m0002220_drop_qualified_purl_gist_indexes; mod m0002230_sle_license_id_index; mod m0002240_product_version_sbom_index; +mod m0002250_create_cpe_status; +mod m0002260_cpe_part_vendor_product_index; pub trait MigratorExt: Send { fn build_migrations() -> Migrations; @@ -147,6 +149,8 @@ impl MigratorExt for Migrator { .normal(m0002220_drop_qualified_purl_gist_indexes::Migration) .normal(m0002230_sle_license_id_index::Migration) .normal(m0002240_product_version_sbom_index::Migration) + .normal(m0002250_create_cpe_status::Migration) + .normal(m0002260_cpe_part_vendor_product_index::Migration) } } diff --git a/migration/src/m0002250_create_cpe_status.rs b/migration/src/m0002250_create_cpe_status.rs new file mode 100644 index 000000000..3a8c6f6c7 --- /dev/null +++ b/migration/src/m0002250_create_cpe_status.rs @@ -0,0 +1,138 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Advisory statuses keyed by CPE (vendor/product identity), mirroring + // purl_status. The referenced CPE carries the affected vendor/product + // with the version component normalized to ANY; the affected versions + // are expressed through the version range. + manager + .create_table( + Table::create() + .table(CpeStatus::Table) + .if_not_exists() + .col( + ColumnDef::new(CpeStatus::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CpeStatus::AdvisoryId).uuid().not_null()) + .col(ColumnDef::new(CpeStatus::VulnerabilityId).text().not_null()) + .col(ColumnDef::new(CpeStatus::StatusId).uuid().not_null()) + .col(ColumnDef::new(CpeStatus::CpeId).uuid().not_null()) + .col(ColumnDef::new(CpeStatus::VersionRangeId).uuid().not_null()) + .col(ColumnDef::new(CpeStatus::ContextCpeId).uuid()) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::AdvisoryId) + .to(Advisory::Table, Advisory::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::VulnerabilityId) + .to(Vulnerability::Table, Vulnerability::Id), + ) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::StatusId) + .to(Status::Table, Status::Id), + ) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::CpeId) + .to(Cpe::Table, Cpe::Id), + ) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::VersionRangeId) + .to(VersionRange::Table, VersionRange::Id), + ) + .foreign_key( + ForeignKey::create() + .from(CpeStatus::Table, CpeStatus::ContextCpeId) + .to(Cpe::Table, Cpe::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(CpeStatus::Table) + .name("cpe_status_cpe_id_idx") + .col(CpeStatus::CpeId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(CpeStatus::Table) + .name("cpe_status_advisory_id_vulnerability_id_idx") + .col(CpeStatus::AdvisoryId) + .col(CpeStatus::VulnerabilityId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().if_exists().table(CpeStatus::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +pub enum CpeStatus { + Table, + Id, + AdvisoryId, + VulnerabilityId, + StatusId, + CpeId, + VersionRangeId, + ContextCpeId, +} + +#[derive(DeriveIden)] +pub enum Advisory { + Table, + Id, +} + +#[derive(DeriveIden)] +pub enum Vulnerability { + Table, + Id, +} + +#[derive(DeriveIden)] +pub enum Status { + Table, + Id, +} + +#[derive(DeriveIden)] +pub enum Cpe { + Table, + Id, +} + +#[derive(DeriveIden)] +pub enum VersionRange { + Table, + Id, +} diff --git a/migration/src/m0002260_cpe_part_vendor_product_index.rs b/migration/src/m0002260_cpe_part_vendor_product_index.rs new file mode 100644 index 000000000..dc5b75be1 --- /dev/null +++ b/migration/src/m0002260_cpe_part_vendor_product_index.rs @@ -0,0 +1,42 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_index( + Index::create() + .if_not_exists() + .table(Cpe::Table) + .name("cpe_part_vendor_product_idx") + .col(Cpe::Part) + .col(Cpe::Vendor) + .col(Cpe::Product) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .if_exists() + .table(Cpe::Table) + .name("cpe_part_vendor_product_idx") + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Cpe { + Table, + Part, + Vendor, + Product, +} diff --git a/modules/fundamental/src/sbom/model/details.rs b/modules/fundamental/src/sbom/model/details.rs index b108f1be2..add03ee58 100644 --- a/modules/fundamental/src/sbom/model/details.rs +++ b/modules/fundamental/src/sbom/model/details.rs @@ -171,7 +171,7 @@ impl SbomDetails { .query_all(Statement::from_sql_and_values( DbBackend::Postgres, raw_sql::product_advisory_info_sql(), - [sbom.sbom_id.into(), statuses.into()], + [sbom.sbom_id.into(), statuses.clone().into()], )) .instrument(info_span!("product_advisory_info_sql")) .await?; @@ -184,6 +184,28 @@ impl SbomDetails { } } + // Raw SQL query 2: CPE-based vulnerability matching through + // sbom_node_cpe_ref x cpe_status (package-level CPE identity, + // populated by the CVE loader from affected[].cpes). Deliberately + // does not apply CONTEXT_CPE_FILTER_SQL: that filter encodes Red Hat + // product-stream membership, which is unrelated to component-level + // CPEs harvested from third-party SBOMs. + let cpe_raw_results = tx + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + raw_sql::cpe_advisory_info_sql(), + [sbom.sbom_id.into(), statuses.into()], + )) + .instrument(info_span!("cpe_advisory_info_sql")) + .await?; + + for row in cpe_raw_results { + match IdSet::from_query_result(&row, "") { + Ok(result) => id_sets.push(result), + Err(err) => return Err(Error::from(err)), + } + } + log::debug!("Combined {} total ID sets", id_sets.len()); // Extract unique IDs for each entity type diff --git a/modules/fundamental/src/sbom/model/raw_sql.rs b/modules/fundamental/src/sbom/model/raw_sql.rs index 51beb5680..5cedfaaef 100644 --- a/modules/fundamental/src/sbom/model/raw_sql.rs +++ b/modules/fundamental/src/sbom/model/raw_sql.rs @@ -35,9 +35,12 @@ pub const CONTEXT_CPE_FILTER_SQL: &str = r#" "#; /// Returns SQL that counts affected vulnerabilities grouped by severity for -/// multiple SBOMs in a single query. Combines both PURL-based matching (via -/// `purl_status` + `version_matches()`) and CPE-based matching (via -/// `product_status` + package name matching). Takes `$1 = Uuid[]` and returns +/// multiple SBOMs in a single query. Combines PURL-based matching (via +/// `purl_status` + `version_matches()`), name-keyed CPE matching (via +/// `product_status` + package name matching), and package-level CPE-identity +/// matching (via `cpe_status` + `version_matches()`, mirroring +/// [`cpe_advisory_info_sql`] so SBOM-list severity counts agree with the +/// details endpoint). Takes `$1 = Uuid[]` and returns /// `(sbom_id, severity, count)` rows. /// /// Uses a shared `sbom_purl_info` CTE (referenced 3x) that PostgreSQL @@ -164,6 +167,44 @@ pub fn batch_severity_counts_sql() -> &'static str { ) ), + -- Package-level CPEs harvested from SBOMs (e.g. SPDX cpe23Type refs), + -- joined back to the owning package for its (fallback) version. Mirrors + -- sbom_cpe_pkgs in cpe_advisory_info_sql(). + sbom_cpe_pkgs AS ( + SELECT + scr.sbom_id, + c.vendor, + c.product, + c.part, + COALESCE(NULLIF(c.version, '*'), sp.version) AS version + FROM input_sboms i + JOIN sbom_node_cpe_ref scr ON scr.sbom_id = i.sbom_id + JOIN cpe c ON scr.cpe_id = c.id + JOIN sbom_package sp ON sp.sbom_id = scr.sbom_id AND sp.node_id = scr.node_id + ), + + -- CPE-based matching via cpe_status (package-level CPE identity, as + -- opposed to cpe_matches_name/_ns above which key off product_status by + -- package name). Vendor+product identity ('a' part only) plus + -- version_matches() against the affected version range -- no + -- sbom_describing_cpe context filter, matching cpe_advisory_info_sql(). + cpe_version_matches AS ( + SELECT DISTINCT + p.sbom_id, + cs.advisory_id, + cs.vulnerability_id + FROM sbom_cpe_pkgs p + JOIN cpe sc ON sc.vendor = p.vendor AND sc.product = p.product AND sc.part = 'a' + JOIN cpe_status cs ON cs.cpe_id = sc.id + JOIN version_range vr ON cs.version_range_id = vr.id + JOIN status ON cs.status_id = status.id + JOIN advisory ON cs.advisory_id = advisory.id + WHERE p.part = 'a' + AND status.slug = 'affected' + AND advisory.deprecated = false + AND version_matches(p.version, vr.*) + ), + -- Union all matches all_affected AS ( SELECT * FROM purl_matches @@ -171,6 +212,8 @@ pub fn batch_severity_counts_sql() -> &'static str { SELECT * FROM cpe_matches_name UNION SELECT * FROM cpe_matches_ns + UNION + SELECT * FROM cpe_version_matches ), -- Pick the highest severity per (sbom, vulnerability), collapsing @@ -205,6 +248,93 @@ pub fn batch_severity_counts_sql() -> &'static str { "# } +/// Returns SQL that finds advisory/vulnerability matches for a single SBOM +/// through package-level CPE identity, keyed by `cpe_status` (populated by the +/// CVE loader from `affected[].cpes`). Takes `$1 = sbom_id (uuid)`, +/// `$2 = statuses (text[])`, and returns rows shaped like +/// [`product_advisory_info_sql`] so callers can merge the two result sets +/// through the same row-assembly code (`IdSet`). +/// +/// Matching is vendor+product identity (`part = 'a'`) plus `version_matches()` +/// against the version range recorded on `cpe_status` -- deliberately *not* +/// filtered by the `sbom_describing_cpe` context filter +/// ([`CONTEXT_CPE_FILTER_SQL`]): that concept encodes Red Hat product-stream +/// membership and is meaningless for component-level CPEs harvested from +/// third-party SBOMs (e.g. SPDX `cpe23Type` external references). +/// +/// Nodes without a `qualified_purl_id` are skipped (`IdSet::qualified_purl_id` +/// is a mandatory `Uuid`); making it optional is left as a follow-up. +pub fn cpe_advisory_info_sql() -> String { + r#" + WITH + -- Package-level CPEs referenced by this SBOM, joined back to the + -- owning package for its purl and (fallback) version. + sbom_cpe_pkgs AS ( + SELECT + scr.sbom_id, + scr.node_id, + c.vendor, + c.product, + c.part, + COALESCE(NULLIF(c.version, '*'), sp.version) AS version, + spr.qualified_purl_id + FROM sbom_node_cpe_ref scr + JOIN cpe c ON scr.cpe_id = c.id + JOIN sbom_package sp ON sp.sbom_id = scr.sbom_id AND sp.node_id = scr.node_id + LEFT JOIN sbom_node_purl_ref spr ON spr.sbom_id = scr.sbom_id AND spr.node_id = scr.node_id + WHERE scr.sbom_id = $1 + ), + + -- CPE-status matches: identity by vendor+product (application CPEs + -- only), affected version range checked via version_matches(). + cpe_status_matches AS ( + SELECT DISTINCT + cs.advisory_id, + cs.vulnerability_id, + cs.status_id, + cs.context_cpe_id, + p.qualified_purl_id, + p.sbom_id, + p.node_id + FROM cpe_status cs + JOIN cpe sc ON cs.cpe_id = sc.id + JOIN sbom_cpe_pkgs p + ON p.vendor = sc.vendor + AND p.product = sc.product + AND sc.part = 'a' + AND p.part = 'a' + JOIN version_range vr ON cs.version_range_id = vr.id + WHERE p.qualified_purl_id IS NOT NULL + AND version_matches(p.version, vr.*) + ) + + -- Final query joins to get all required fields (mirrors the tail of + -- product_advisory_info_sql). + SELECT DISTINCT + "advisory"."id" AS "advisory_id", + "advisory_vulnerability"."advisory_id" AS "av_advisory_id", + "advisory_vulnerability"."vulnerability_id" AS "av_vulnerability_id", + "vulnerability"."id" AS "vulnerability_id", + m.qualified_purl_id AS "qualified_purl_id", + m.sbom_id AS "sbom_id", + m.node_id AS "node_id", + "status"."id" AS "status_id", + "cpe"."id" AS "cpe_id", + "organization"."id" AS "organization_id" + FROM cpe_status_matches m + JOIN "status" ON m.status_id = "status"."id" + JOIN "advisory" ON m.advisory_id = "advisory"."id" + LEFT JOIN "organization" ON "advisory"."issuer_id" = "organization"."id" + JOIN "advisory_vulnerability" ON m.advisory_id = "advisory_vulnerability"."advisory_id" + AND m.vulnerability_id = "advisory_vulnerability"."vulnerability_id" + JOIN "vulnerability" ON "advisory_vulnerability"."vulnerability_id" = "vulnerability"."id" + LEFT JOIN "cpe" ON m.context_cpe_id = "cpe"."id" + WHERE ($2::text[] = ARRAY[]::text[] OR "status"."slug" = ANY($2::text[])) + AND "advisory"."deprecated" = false + "# + .to_string() +} + pub fn product_advisory_info_sql() -> String { r#" WITH diff --git a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs index 9798c6583..82d298932 100644 --- a/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs +++ b/modules/fundamental/src/vulnerability/model/details/vulnerability_advisory.rs @@ -257,6 +257,93 @@ impl VulnerabilityAdvisorySummary { .collect::, _>>()?, ); + // Package-level CPE matches: the reverse of `cpe_advisory_info_sql`. + // A vulnerability's `cpe_status` rows (vendor/product identity + affected + // version range) are matched against the package CPEs an SBOM harvested + // into `sbom_node_cpe_ref`. Without this, an SBOM that matches a + // vulnerability *only* via a package CPE (e.g. NVD-sourced applicability) + // shows the vulnerability on the SBOM page but has no backlink here. + let cpe_status_query = r#" + SELECT + "cpe_status"."advisory_id", + "sbom_node_purl_ref"."sbom_id", + "sbom_node_purl_ref"."node_id", + "sbom_node_purl_ref"."qualified_purl_id", + "sbom"."sbom_id" AS "sbom$sbom_id", + "sbom"."node_id" AS "sbom$node_id", + "sbom"."document_id" AS "sbom$document_id", + "sbom"."published" AS "sbom$published", + "sbom"."authors" AS "sbom$authors", + "sbom"."suppliers" AS "sbom$suppliers", + "sbom"."data_licenses" AS "sbom$data_licenses", + "sbom"."source_document_id" AS "sbom$source_document_id", + "sbom"."labels" AS "sbom$labels", + "sbom"."properties" AS "sbom$properties", + "sbom"."revision" AS "sbom$revision", + "sbom_package"."sbom_id" AS "sbom_package$sbom_id", + "sbom_package"."node_id" AS "sbom_package$node_id", + "sbom_package"."version" AS "sbom_package$version", + "sbom_node"."sbom_id" AS "sbom_node$sbom_id", + "sbom_node"."node_id" AS "sbom_node$node_id", + "sbom_node"."name" AS "sbom_node$name", + "status"."id" AS "status$id", + "status"."slug" AS "status$slug", + "status"."name" AS "status$name", + "status"."description" AS "status$description", + "qualified_purl"."id" AS "qualified_purl$id", + "qualified_purl"."versioned_purl_id" AS "qualified_purl$versioned_purl_id", + "qualified_purl"."qualifiers" AS "qualified_purl$qualifiers", + "qualified_purl"."purl" AS "qualified_purl$purl" + FROM "cpe_status" + JOIN "status" ON "cpe_status"."status_id" = "status"."id" + JOIN "advisory" ON "cpe_status"."advisory_id" = "advisory"."id" AND "advisory"."deprecated" = false + JOIN "version_range" ON "cpe_status"."version_range_id" = "version_range"."id" + + -- the advisory's identity CPE (application CPEs only) + JOIN "cpe" AS "adv_cpe" ON "cpe_status"."cpe_id" = "adv_cpe"."id" AND "adv_cpe"."part" = 'a' + + -- SBOM package CPEs matching that vendor/product identity + JOIN "cpe" AS "pkg_cpe" ON "pkg_cpe"."vendor" = "adv_cpe"."vendor" + AND "pkg_cpe"."product" = "adv_cpe"."product" + AND "pkg_cpe"."part" = 'a' + JOIN "sbom_node_cpe_ref" ON "sbom_node_cpe_ref"."cpe_id" = "pkg_cpe"."id" + JOIN "sbom" ON "sbom"."sbom_id" = "sbom_node_cpe_ref"."sbom_id" + + -- the matched component package (for the version fallback) and its purl + JOIN "sbom_package" AS "matched_pkg" ON "matched_pkg"."sbom_id" = "sbom_node_cpe_ref"."sbom_id" AND "matched_pkg"."node_id" = "sbom_node_cpe_ref"."node_id" + JOIN "sbom_node_purl_ref" ON "sbom_node_purl_ref"."sbom_id" = "sbom_node_cpe_ref"."sbom_id" AND "sbom_node_purl_ref"."node_id" = "sbom_node_cpe_ref"."node_id" + JOIN "qualified_purl" ON "qualified_purl"."id" = "sbom_node_purl_ref"."qualified_purl_id" + + -- the SBOM's describing node/package (identity; name and version) + JOIN "sbom_node" ON "sbom_node"."sbom_id" = "sbom"."sbom_id" AND "sbom_node"."node_id" = "sbom"."node_id" + JOIN "package_relates_to_package" ON "package_relates_to_package"."sbom_id" = "sbom_node"."sbom_id" AND "relationship" = $2 + JOIN "sbom_package" ON "sbom_package"."sbom_id" = "package_relates_to_package"."sbom_id" AND "sbom_package"."node_id" = "package_relates_to_package"."right_node_id" + + WHERE + "cpe_status"."vulnerability_id" = $1 + AND "status"."slug" != 'not_affected' + AND version_matches(COALESCE(NULLIF("pkg_cpe"."version", '*'), "matched_pkg"."version"), "version_range".*) + "#; + + let cpe_result: Vec = tx + .query_all(Statement::from_sql_and_values( + DbBackend::Postgres, + cpe_status_query, + [ + vulnerability.id.clone().into(), + Relationship::Describes.into(), + ], + )) + .instrument(info_span!("fetching cpe status")) + .await?; + + vuln_sbom_statuses.extend( + cpe_result + .iter() + .map(|row| SbomStatusCatcher::from_query_result(row, "")) + .collect::, _>>()?, + ); + // Purl statuses might make sense only when we don't have any sboms // so we can show what we know about affected purls // It's a legacy at this point as some tests depends on it, diff --git a/modules/fundamental/tests/sbom/details.rs b/modules/fundamental/tests/sbom/details.rs index 9d27210d8..ab939fefe 100644 --- a/modules/fundamental/tests/sbom/details.rs +++ b/modules/fundamental/tests/sbom/details.rs @@ -3,7 +3,11 @@ use test_log::test; use tracing::instrument; use trustify_common::{db::pagination_cache::PaginationCache, id::Id}; use trustify_module_fundamental::common::model::{Score, ScoreType, ScoredVector, Severity}; -use trustify_module_fundamental::sbom::{model::details::SbomDetails, service::SbomService}; +use trustify_module_fundamental::sbom::{ + model::{AffectedSeverity, details::SbomDetails}, + service::SbomService, +}; +use trustify_module_ingestor::service::Format; use trustify_test_context::TrustifyContext; #[test_context(TrustifyContext)] @@ -209,6 +213,301 @@ async fn sbom_details_cyclonedx_osv(ctx: &TrustifyContext) -> Result<(), anyhow: Ok(()) } +/// End-to-end test of the CPE-based matching path added in +/// `raw_sql::cpe_advisory_info_sql`: package-level CPEs harvested from an +/// SPDX SBOM (`sbom_node_cpe_ref`) matched against `cpe_status` rows written +/// by the CVE loader from `affected[].cpes`. +/// +/// Ingests the synthetic firmware SBOM (OpenSSL 0.9.8w, BusyBox 1.19.4, and +/// a package with an unparseable/junk CPE) plus three synthetic CVE fixtures: +/// - CVE-2099-0001: affects openssl (exact `0.9.8w` + semver range) and +/// busybox (concrete-CPE-version fallback, no `versions` list). +/// - CVE-2099-0002: ADP-only, targets `denx:u-boot`, which this SBOM does not +/// contain -- must not surface any advisory here. +/// - CVE-2099-0003: affects openssl only in the `2.0.0..3.0.0` semver range, +/// which does NOT include `0.9.8w` -- the negative-match case that +/// differentiates this path from name-only `product_status` matching. +#[test_context(TrustifyContext)] +#[test(tokio::test)] +#[instrument] +async fn sbom_details_cpe_matching(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let sbom = SbomService::new(PaginationCache::for_test()); + + let result = ctx.ingest_document("spdx/cpe23-firmware.json").await?; + + let cve1 = ctx.ingest_document("cve/CVE-2099-0001.json").await?; + assert_eq!(cve1.document_id, Some("CVE-2099-0001".to_string())); + + let cve2 = ctx.ingest_document("cve/CVE-2099-0002.json").await?; + assert_eq!(cve2.document_id, Some("CVE-2099-0002".to_string())); + + let cve3 = ctx.ingest_document("cve/CVE-2099-0003.json").await?; + assert_eq!(cve3.document_id, Some("CVE-2099-0003".to_string())); + + let details = sbom + .fetch_sbom_details(Id::parse_uuid(result.id)?, vec![], &ctx.db) + .await? + .expect("SBOM details must be found"); + log::info!("SBOM details: {details:?}"); + + // CVE-2099-0001 must show up, with OpenSSL (via the exact version match) + // and BusyBox (via the concrete-CPE-version fallback) as affected + // packages. + let advisory_1 = details + .advisories + .iter() + .find(|a| a.head.document_id == "CVE-2099-0001") + .expect("CVE-2099-0001 advisory must be present"); + assert_eq!(1, advisory_1.status.len()); + assert_eq!("affected", advisory_1.status[0].status); + let package_names: std::collections::BTreeSet = advisory_1.status[0] + .packages + .iter() + .map(|p| p.name.clone()) + .collect(); + assert!( + package_names.contains("OpenSSL"), + "expected OpenSSL among matched packages, got {package_names:?}" + ); + assert!( + package_names.contains("BusyBox"), + "expected BusyBox among matched packages, got {package_names:?}" + ); + + // CVE-2099-0002 (ADP-only, denx:u-boot) must NOT match -- this SBOM + // contains no u-boot package. + assert!( + !details + .advisories + .iter() + .any(|a| a.head.document_id == "CVE-2099-0002"), + "CVE-2099-0002 (u-boot) must not match any package in this SBOM" + ); + + // CVE-2099-0003 (openssl, 2.0.0..3.0.0) must NOT match -- 0.9.8w falls + // outside that range. This is the key false-positive guard: vendor+ + // product identity alone (as name-only product_status matching would + // use) is not sufficient, version_matches() must also hold. + assert!( + !details + .advisories + .iter() + .any(|a| a.head.document_id == "CVE-2099-0003"), + "CVE-2099-0003 (openssl 2.0.0..3.0.0) must not match openssl 0.9.8w" + ); + + Ok(()) +} + +/// Parity test for PR-7: `batch_advisory_severity_counts` (used by the SBOM +/// list endpoint) must agree with `fetch_sbom_details` (the details +/// endpoint) once CPE-status matches are included in its `all_affected` +/// UNION. Before PR-7, this CPE-matched vulnerability was invisible to the +/// batch/list severity counts even though the details endpoint already +/// surfaced it (PR-6), a discrepancy this test guards against regressing. +#[test_context(TrustifyContext)] +#[test(tokio::test)] +#[instrument] +async fn sbom_severity_counts_cpe_matching(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let sbom = SbomService::new(PaginationCache::for_test()); + + let result = ctx.ingest_document("spdx/cpe23-firmware.json").await?; + ctx.ingest_document("cve/CVE-2099-0001.json").await?; + ctx.ingest_document("cve/CVE-2099-0002.json").await?; + ctx.ingest_document("cve/CVE-2099-0003.json").await?; + + let sbom_id = Id::parse_uuid(result.id)?; + let Id::Uuid(sbom_uuid) = sbom_id else { + panic!("expected a UUID sbom id"); + }; + + let details = sbom + .fetch_sbom_details(sbom_id, vec![], &ctx.db) + .await? + .expect("SBOM details must be found"); + + // Non-zero: the details endpoint (PR-6) already showed CVE-2099-0001 as + // affected via the CPE path (OpenSSL + BusyBox); the batch counts must + // see it too. + assert_eq!( + 1, + details.advisories.len(), + "expected exactly CVE-2099-0001 to match, got {:?}", + details + .advisories + .iter() + .map(|a| &a.head.document_id) + .collect::>() + ); + + let counts = sbom + .batch_advisory_severity_counts(&[sbom_uuid], &ctx.db) + .await?; + let sbom_counts = counts + .get(&sbom_uuid) + .expect("severity counts must be present for this SBOM"); + + // CVE-2099-0001 carries a single CVSS v3.1 CRITICAL score; the ADP-only + // (u-boot) and version-excluded (openssl 2.0.0..3.0.0) CVEs must not + // contribute any count. + assert_eq!( + 1, + sbom_counts.values().sum::(), + "expected exactly one counted vulnerability, got {sbom_counts:?}" + ); + assert_eq!( + Some(&1), + sbom_counts.get(&AffectedSeverity::Critical), + "expected one critical-severity vulnerability, got {sbom_counts:?}" + ); + + Ok(()) +} + +/// End-to-end test that NVD-sourced CPE applicability drives range-based SBOM +/// matching. The whole reason for the NVD importer: NVD carries version *ranges* +/// (`versionStartIncluding`/`versionEndExcluding`) which, stored under the +/// `semver` scheme, match a component version that is neither range boundary -- +/// something the sparse cvelistv5 CPE data (stored under the exact-only `generic` +/// scheme) cannot do. +/// +/// NVD documents are ingested with an explicit `Format::NVD`: a bare NVD `cve` +/// object is indistinguishable from OSV by content sniffing. +#[test_context(TrustifyContext)] +#[test(tokio::test)] +#[instrument] +async fn sbom_details_nvd_cpe_range_matching(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + let sbom = SbomService::new(PaginationCache::for_test()); + + let result = ctx.ingest_document("spdx/cpe23-firmware.json").await?; + + let hit = ctx + .ingest_document_as("nvd/CVE-2099-2000.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!(hit.document_id, Some("CVE-2099-2000".to_string())); + let miss = ctx + .ingest_document_as("nvd/CVE-2099-2001.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!(miss.document_id, Some("CVE-2099-2001".to_string())); + + // Unbounded range: only a lower bound (`versionStartIncluding` 1.0.0, no + // upper bound). Exercises `Version::Unbounded` on the end side; 1.19.4 >= + // 1.0.0 so this matches. + let unbounded_start_hit = ctx + .ingest_document_as("nvd/CVE-2099-2002.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!( + unbounded_start_hit.document_id, + Some("CVE-2099-2002".to_string()) + ); + // Unbounded range: only an upper bound (`versionEndExcluding` 2.0.0, no + // lower bound). Exercises `Version::Unbounded` on the start side; 1.19.4 < + // 2.0.0 so this matches. + let unbounded_end_hit = ctx + .ingest_document_as("nvd/CVE-2099-2003.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!( + unbounded_end_hit.document_id, + Some("CVE-2099-2003".to_string()) + ); + // Inclusive lower boundary: the component version equals + // `versionStartIncluding` (1.19.4 == 1.19.4). Must match -- catches an + // off-by-one that treats the inclusive start as exclusive. + let inclusive_start_hit = ctx + .ingest_document_as("nvd/CVE-2099-2004.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!( + inclusive_start_hit.document_id, + Some("CVE-2099-2004".to_string()) + ); + // Exclusive upper boundary: the component version equals + // `versionEndExcluding` (1.19.4 == 1.19.4). Must NOT match -- catches an + // off-by-one that treats the exclusive end as inclusive. + let exclusive_end_miss = ctx + .ingest_document_as("nvd/CVE-2099-2005.json", Format::NVD, ("source", "nvd")) + .await?; + assert_eq!( + exclusive_end_miss.document_id, + Some("CVE-2099-2005".to_string()) + ); + + let details = sbom + .fetch_sbom_details(Id::parse_uuid(result.id)?, vec![], &ctx.db) + .await? + .expect("SBOM details must be found"); + log::info!("SBOM details: {details:?}"); + + // Positive: busybox 1.19.4 falls inside [1.0.0, 2.0.0). It is neither bound, + // so this only matches because the semver scheme does ordered range + // comparison -- the exact-only `generic` scheme would have missed it. + let advisory = details + .advisories + .iter() + .find(|a| a.head.document_id == "CVE-2099-2000") + .expect("CVE-2099-2000 must match busybox 1.19.4 via a semver range"); + let package_names: std::collections::BTreeSet = advisory.status[0] + .packages + .iter() + .map(|p| p.name.clone()) + .collect(); + assert!( + package_names.contains("BusyBox"), + "expected BusyBox among matched packages, got {package_names:?}" + ); + + // Negative: [2.0.0, 3.0.0) excludes 1.19.4. Vendor/product identity alone is + // not sufficient; the version bound must exclude it. + assert!( + !details + .advisories + .iter() + .any(|a| a.head.document_id == "CVE-2099-2001"), + "CVE-2099-2001 (busybox 2.0.0..3.0.0) must not match busybox 1.19.4" + ); + + // Asserts that `document_id` matches busybox 1.19.4 in this SBOM, i.e. its + // version range covers the component version. + let matches_busybox = |document_id: &str| { + details + .advisories + .iter() + .find(|a| a.head.document_id == document_id) + .is_some_and(|advisory| { + advisory + .status + .iter() + .flat_map(|status| status.packages.iter()) + .any(|p| p.name == "BusyBox") + }) + }; + + // Unbounded end: [1.0.0, ∞) covers 1.19.4. + assert!( + matches_busybox("CVE-2099-2002"), + "CVE-2099-2002 (busybox >= 1.0.0, unbounded end) must match busybox 1.19.4" + ); + // Unbounded start: (∞, 2.0.0) covers 1.19.4. + assert!( + matches_busybox("CVE-2099-2003"), + "CVE-2099-2003 (busybox < 2.0.0, unbounded start) must match busybox 1.19.4" + ); + // Inclusive lower bound sits exactly on the component version. + assert!( + matches_busybox("CVE-2099-2004"), + "CVE-2099-2004 (busybox [1.19.4, 2.0.0)) must match busybox 1.19.4 at the inclusive lower bound" + ); + // Exclusive upper bound sits exactly on the component version -- excluded. + assert!( + !details + .advisories + .iter() + .any(|a| a.head.document_id == "CVE-2099-2005"), + "CVE-2099-2005 (busybox [1.0.0, 1.19.4)) must not match busybox 1.19.4 at the exclusive upper bound" + ); + + Ok(()) +} + /// Constructs a `ScoredVector` from its parts, deriving the severity from the type and value. fn sv(r#type: ScoreType, value: f64, vector: impl Into) -> ScoredVector { ScoredVector { diff --git a/modules/fundamental/tests/sbom/spdx.rs b/modules/fundamental/tests/sbom/spdx.rs index ab8d3aba8..2a64e740c 100644 --- a/modules/fundamental/tests/sbom/spdx.rs +++ b/modules/fundamental/tests/sbom/spdx.rs @@ -166,6 +166,60 @@ async fn ingest_spdx_broken_refs(ctx: &TrustifyContext) -> Result<(), anyhow::Er Ok(()) } +#[test_context(TrustifyContext)] +#[test(tokio::test)] +async fn ingest_spdx_cpe23_refs(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + test_with_spdx( + ctx, + "spdx/cpe23-firmware.json", + |WithContext { service, sbom, .. }| async move { + let packages = service + .fetch_sbom_packages( + sbom.sbom.sbom_id, + Default::default(), + Paginated { + offset: 0, + limit: 100, + total: true, + }, + &ctx.db, + ) + .await?; + + let find = |name: &str| { + packages + .items + .iter() + .find(|p| p.name == name) + .unwrap_or_else(|| panic!("package {name} must exist")) + }; + + let openssl = find("OpenSSL"); + assert_eq!(openssl.cpe, vec!["cpe:/a:openssl:openssl:0.9.8w:*:*:*"]); + + let busybox = find("BusyBox"); + assert_eq!(busybox.cpe, vec!["cpe:/a:busybox:busybox:1.19.4:*:*:*"]); + + let windows = find("Windows"); + assert_eq!( + windows.cpe, + vec!["cpe:/o:microsoft:windows_10:1607:*~*~*~*~x64~*:en-US"] + ); + + // the unparseable CPE reference is skipped, but the package is still ingested + let garbage = packages + .items + .iter() + .find(|p| p.name.starts_with("libfoo")) + .expect("package with broken CPE must exist"); + assert!(garbage.cpe.is_empty()); + + Ok(()) + }, + ) + .await +} + #[instrument(skip(ctx, f))] pub async fn test_with_spdx(ctx: &TrustifyContext, sbom: &str, f: F) -> anyhow::Result<()> where diff --git a/modules/fundamental/tests/vuln/mod.rs b/modules/fundamental/tests/vuln/mod.rs index b45fa8bdc..1530d9d30 100644 --- a/modules/fundamental/tests/vuln/mod.rs +++ b/modules/fundamental/tests/vuln/mod.rs @@ -5,9 +5,49 @@ use serde_json::json; use test_context::test_context; use test_log::test; use trustify_common::db::pagination_cache::PaginationCache; +use trustify_common::id::Id; use trustify_module_fundamental::vulnerability::service::VulnerabilityService; +use trustify_module_ingestor::service::Format; use trustify_test_context::{Dataset, TrustifyContext, subset::ContainsSubset}; +/// Reverse of the SBOM-page CPE match: a vulnerability's details must backlink +/// the SBOMs that match it via a package CPE (not just via PURL / product_status). +/// Guards the asymmetry where an SBOM showed the vulnerability but the +/// vulnerability page listed no related SBOMs. +#[test_context(TrustifyContext)] +#[test(tokio::test)] +async fn vuln_related_sboms_via_cpe(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + // Firmware SBOM carries busybox 1.19.4 (package CPE); the NVD advisory marks + // busybox affected in [1.0.0, 2.0.0), which includes 1.19.4 via semver range. + let sbom = ctx.ingest_document("spdx/cpe23-firmware.json").await?; + ctx.ingest_document_as("nvd/CVE-2099-2000.json", Format::NVD, ("source", "nvd")) + .await?; + + let Id::Uuid(sbom_uuid) = Id::parse_uuid(sbom.id)? else { + panic!("expected a UUID sbom id"); + }; + + let service = VulnerabilityService::new(PaginationCache::for_test()); + let details = service + .fetch_vulnerability("CVE-2099-2000", Default::default(), false, &ctx.db) + .await? + .expect("vulnerability must exist"); + + let sbom_ids: Vec<_> = details + .advisories + .iter() + .flat_map(|a| a.sboms.iter()) + .map(|s| s.head.id) + .collect(); + + assert!( + sbom_ids.contains(&sbom_uuid), + "firmware SBOM must be backlinked from CVE-2099-2000 via the CPE match, got {sbom_ids:?}" + ); + + Ok(()) +} + #[test_context(TrustifyContext)] #[test(tokio::test)] async fn issue_1840(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { diff --git a/modules/importer/Cargo.toml b/modules/importer/Cargo.toml index f41ec6a3c..2b7f04883 100644 --- a/modules/importer/Cargo.toml +++ b/modules/importer/Cargo.toml @@ -55,6 +55,7 @@ zip = { workspace = true } [dev-dependencies] actix-http = { workspace = true } bytesize = { workspace = true } +liblzma = { workspace = true } test-log = { workspace = true, features = ["log", "trace"] } test-context = { workspace = true } trustify-test-context = { workspace = true } diff --git a/modules/importer/schema/importer.json b/modules/importer/schema/importer.json index 91a28cb5a..9ee2b23d4 100644 --- a/modules/importer/schema/importer.json +++ b/modules/importer/schema/importer.json @@ -50,6 +50,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "nvd": { + "$ref": "#/$defs/NvdImporter" + } + }, + "required": [ + "nvd" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -355,6 +367,58 @@ "period" ] }, + "NvdImporter": { + "type": "object", + "properties": { + "disabled": { + "description": "A flag to disable the importer, without deleting it.", + "type": "boolean", + "default": false + }, + "period": { + "description": "The period the importer should be run.", + "$ref": "#/$defs/HumantimeSerde" + }, + "description": { + "description": "A description for users.", + "type": [ + "string", + "null" + ] + }, + "labels": { + "description": "Labels which will be applied to the ingested documents.", + "$ref": "#/$defs/Labels" + }, + "source": { + "description": "The base URL of the GitHub repository publishing NVD data as per-year\nrelease assets (`CVE-.json.xz` + `.meta`), in the NVD-API JSON\nschema. The latest release is always used.", + "type": "string", + "default": "https://github.com/fkie-cad/nvd-json-data-feeds" + }, + "years": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + }, + "startYear": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "period" + ] + }, "ClearlyDefinedImporter": { "type": "object", "properties": { diff --git a/modules/importer/src/model/mod.rs b/modules/importer/src/model/mod.rs index e4196c056..ec2fed22f 100644 --- a/modules/importer/src/model/mod.rs +++ b/modules/importer/src/model/mod.rs @@ -4,6 +4,7 @@ mod clearly_defined; mod csaf; mod cve; mod cwe; +mod nvd; mod osv; mod quay; mod sbom; @@ -14,6 +15,7 @@ pub use clearly_defined_curation::*; pub use csaf::*; pub use cve::*; pub use cwe::*; +pub use nvd::*; pub use osv::*; pub use quay::*; pub use sbom::*; @@ -179,6 +181,7 @@ pub enum ImporterConfiguration { Csaf(CsafImporter), Osv(OsvImporter), Cve(CveImporter), + Nvd(NvdImporter), ClearlyDefined(ClearlyDefinedImporter), ClearlyDefinedCuration(ClearlyDefinedCurationImporter), Cwe(CweImporter), @@ -194,6 +197,7 @@ impl Deref for ImporterConfiguration { Self::Csaf(importer) => &importer.common, Self::Osv(importer) => &importer.common, Self::Cve(importer) => &importer.common, + Self::Nvd(importer) => &importer.common, Self::ClearlyDefined(importer) => &importer.common, Self::ClearlyDefinedCuration(importer) => &importer.common, Self::Cwe(importer) => &importer.common, @@ -209,6 +213,7 @@ impl DerefMut for ImporterConfiguration { Self::Csaf(importer) => &mut importer.common, Self::Osv(importer) => &mut importer.common, Self::Cve(importer) => &mut importer.common, + Self::Nvd(importer) => &mut importer.common, Self::ClearlyDefined(importer) => &mut importer.common, Self::ClearlyDefinedCuration(importer) => &mut importer.common, Self::Cwe(importer) => &mut importer.common, diff --git a/modules/importer/src/model/nvd.rs b/modules/importer/src/model/nvd.rs new file mode 100644 index 000000000..1036b9373 --- /dev/null +++ b/modules/importer/src/model/nvd.rs @@ -0,0 +1,57 @@ +use super::*; +use std::collections::HashSet; + +#[derive( + Clone, + Debug, + PartialEq, + Eq, + serde::Serialize, + serde::Deserialize, + ToSchema, + schemars::JsonSchema, +)] +#[serde(rename_all = "camelCase")] +pub struct NvdImporter { + #[serde(flatten)] + pub common: CommonImporter, + + /// The base URL of the GitHub repository publishing NVD data as per-year + /// release assets (`CVE-.json.xz` + `.meta`), in the NVD-API JSON + /// schema. The latest release is always used. + #[serde(default = "default::source")] + pub source: String, + + /// An explicit set of feed years to import. When non-empty, exactly these + /// years are imported and `start_year` is ignored. + #[serde(default, skip_serializing_if = "HashSet::is_empty")] + pub years: HashSet, + + /// The first feed year to import when `years` is empty; all years from + /// here through the current year are imported. Defaults to 1999, the + /// first year with NVD data. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_year: Option, +} + +pub const DEFAULT_SOURCE_NVD: &str = "https://github.com/fkie-cad/nvd-json-data-feeds"; + +mod default { + pub fn source() -> String { + super::DEFAULT_SOURCE_NVD.into() + } +} + +impl Deref for NvdImporter { + type Target = CommonImporter; + + fn deref(&self) -> &Self::Target { + &self.common + } +} + +impl DerefMut for NvdImporter { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.common + } +} diff --git a/modules/importer/src/runner/mod.rs b/modules/importer/src/runner/mod.rs index 60f1eb4cb..c242503fd 100644 --- a/modules/importer/src/runner/mod.rs +++ b/modules/importer/src/runner/mod.rs @@ -6,6 +6,7 @@ pub mod context; pub mod csaf; pub mod cve; pub mod cwe; +pub mod nvd; pub mod osv; pub mod progress; pub mod quay; @@ -52,6 +53,7 @@ impl ImportRunner { } ImporterConfiguration::Osv(osv) => self.run_once_osv(context, osv, continuation).await, ImporterConfiguration::Cve(cve) => self.run_once_cve(context, cve, continuation).await, + ImporterConfiguration::Nvd(nvd) => self.run_once_nvd(context, nvd, continuation).await, ImporterConfiguration::ClearlyDefined(clearly_defined) => { self.run_once_clearly_defined(context, clearly_defined, continuation) .await diff --git a/modules/importer/src/runner/nvd/mod.rs b/modules/importer/src/runner/nvd/mod.rs new file mode 100644 index 000000000..580360a44 --- /dev/null +++ b/modules/importer/src/runner/nvd/mod.rs @@ -0,0 +1,329 @@ +use crate::{ + model::NvdImporter, + runner::{ + RunOutput, + context::RunContext, + progress::{Progress, ProgressInstance}, + report::{Phase, ReportBuilder, ScannerError}, + }, +}; +use std::collections::{BTreeSet, HashMap}; +use time::OffsetDateTime; +use tracing::instrument; +use trustify_entity::labels::Labels; +use trustify_module_ingestor::{ + graph::Graph, + service::{Cache, Format, IngestorService, advisory::nvd::schema::NvdYearFeed}, +}; + +/// The earliest CVE year published by NVD. +const NVD_FIRST_YEAR: u16 = 1999; + +impl super::ImportRunner { + #[instrument(skip(self, context), err(level=tracing::Level::INFO))] + pub async fn run_once_nvd( + &self, + context: impl RunContext + 'static, + nvd: NvdImporter, + continuation: serde_json::Value, + ) -> Result { + let ingestor = + IngestorService::new(Graph::new(), self.storage.clone(), self.analysis.clone()); + + let mut report = ReportBuilder::new(); + + // Per-year sha256 of the last successfully ingested feed asset. A year + // is re-ingested only when its `.meta` sha256 differs, so steady-state + // runs transfer just the changed years. + let mut state: HashMap = + serde_json::from_value(continuation).unwrap_or_default(); + + let base = format!( + "{}/releases/latest/download", + nvd.source.trim_end_matches('/') + ); + + let client = reqwest::Client::builder() + .user_agent("trustify-nvd-importer") + .build() + .map_err(|err| ScannerError::Critical(err.into()))?; + + let years = resolve_years(&nvd); + + let progress = context.progress(format!("Import NVD: {}", nvd.source)); + let mut instance = progress.start(years.len()); + + for year in years { + if context.is_canceled().await { + break; + } + + progress.message(format!("NVD {year}")).await; + + if let Err(err) = self + .ingest_year(&client, &ingestor, &base, year, &mut state, &mut report) + .await + { + // Record and continue: one bad year must not abort the rest, and + // its state is left untouched so it retries next run. + tracing::warn!("Failed to process NVD year {year}: {err}"); + report.add_error(Phase::Retrieval, format!("CVE-{year}"), err.to_string()); + } + + instance.tick().await; + } + + instance.finish().await; + + Ok(RunOutput { + report: report.build(), + continuation: serde_json::to_value(state).ok(), + }) + } + + /// Downloads, decompresses and ingests a single year's feed asset, skipping + /// the work when the asset's sha256 is unchanged since the last run. + async fn ingest_year( + &self, + client: &reqwest::Client, + ingestor: &IngestorService, + base: &str, + year: u16, + state: &mut HashMap, + report: &mut ReportBuilder, + ) -> anyhow::Result<()> { + let asset = format!("CVE-{year}.json.xz"); + + // 1. Cheap change check via the `.meta` sidecar. + let sha256 = fetch_meta_sha256(client, &format!("{base}/CVE-{year}.meta")).await?; + if let (Some(sha256), Some(prev)) = (&sha256, state.get(&year)) + && sha256 == prev + { + tracing::info!("NVD {year}: unchanged (sha256 {sha256}), skipping"); + return Ok(()); + } + + // 2. Download + decompress the year feed. + let compressed = client + .get(format!("{base}/{asset}")) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + + let decompressed = walker_common::compression::decompress(compressed, &asset)?; + let feed: NvdYearFeed = serde_json::from_slice(decompressed.as_ref())?; + + // 3. Ingest each CVE record, one transaction per record (matches the + // CVE importer). The raw fragment bytes are handed straight to the + // NVD loader. + let count = feed.cve_items.len(); + for item in &feed.cve_items { + let data = item.get().as_bytes(); + let result = self + .db + .transaction(async |tx| { + ingestor + .ingest( + data, + Format::NVD, + Labels::new().add("source", "nvd").add("file", &asset), + None, + Cache::Skip, + tx, + ) + .await + }) + .await; + + match result { + Ok(_) => report.tick(), + Err(err) => report.add_error(Phase::Upload, asset.clone(), err.to_string()), + } + } + + tracing::info!("NVD {year}: ingested {count} records"); + + // 4. Record the sha256 so the next run can skip this year if unchanged. + if let Some(sha256) = sha256 { + state.insert(year, sha256); + } + + Ok(()) + } +} + +/// The set of years to process: the explicit `years` set if provided, otherwise +/// `start_year` (default 1999) through the current UTC year, inclusive. +/// +/// A non-empty `years` takes precedence; `start_year` is ignored in that case. +fn resolve_years(nvd: &NvdImporter) -> BTreeSet { + if !nvd.years.is_empty() { + if nvd.start_year.is_some() { + tracing::warn!( + "NVD importer has both `years` and `startYear` configured; using the explicit `years` set and ignoring `startYear`" + ); + } + return nvd.years.iter().copied().collect(); + } + + let current = OffsetDateTime::now_utc().year().clamp(0, u16::MAX as i32) as u16; + let start = nvd.start_year.unwrap_or(NVD_FIRST_YEAR); + (start..=current).collect() +} + +/// Parses the `sha256:` line from a `.meta` sidecar. Returns `None` if the field +/// is absent (in which case the year is always re-ingested). +async fn fetch_meta_sha256(client: &reqwest::Client, url: &str) -> anyhow::Result> { + let meta = client + .get(url) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(meta.lines().find_map(|line| { + line.strip_prefix("sha256:") + .map(|value| value.trim().to_string()) + })) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::runner::ImportRunner; + use liblzma::write::XzEncoder; + use std::io::Write; + use test_context::test_context; + use test_log::test; + use trustify_common::db::ReadWrite; + use trustify_test_context::TrustifyContext; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + + fn importer(years: &[u16], start_year: Option, source: &str) -> NvdImporter { + NvdImporter { + common: Default::default(), + source: source.to_string(), + years: years.iter().copied().collect(), + start_year, + } + } + + #[test] + fn resolve_years_explicit_set_wins() { + let nvd = importer(&[2001, 2003], Some(1999), ""); + assert_eq!( + resolve_years(&nvd).into_iter().collect::>(), + vec![2001, 2003] + ); + } + + #[test] + fn resolve_years_start_year_through_current() { + let current = OffsetDateTime::now_utc().year() as u16; + let nvd = importer(&[], Some(current - 2), ""); + assert_eq!( + resolve_years(&nvd).into_iter().collect::>(), + vec![current - 2, current - 1, current] + ); + } + + #[test] + fn resolve_years_defaults_to_first_nvd_year() { + let nvd = importer(&[], None, ""); + let years = resolve_years(&nvd); + assert_eq!(years.first(), Some(&NVD_FIRST_YEAR)); + assert_eq!( + years.last(), + Some(&(OffsetDateTime::now_utc().year() as u16)) + ); + } + + /// xz-compress a per-year feed envelope around the existing NVD test record. + fn feed_asset() -> Vec { + let feed = format!( + r#"{{"cve_items": [{}]}}"#, + include_str!("../../../../../etc/test-data/nvd/CVE-2099-1000.json") + ); + + let mut encoder = XzEncoder::new(Vec::new(), 6); + encoder.write_all(feed.as_bytes()).expect("xz encode"); + encoder.finish().expect("xz finish") + } + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn run_mock_nvd(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + // Start a background HTTP server on a random local port + let nvd = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/releases/latest/download/CVE-2099.meta")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("lastModifiedDate:2099-01-05T12:30:00+00:00\nsha256:0000000000000000000000000000000000000000000000000000000000000000\n"), + ) + .mount(&nvd) + .await; + Mock::given(method("GET")) + .and(path("/releases/latest/download/CVE-2099.json.xz")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(feed_asset())) + .mount(&nvd) + .await; + + let runner = ImportRunner { + db: ReadWrite::new(ctx.db.clone()), + storage: ctx.storage.clone().into(), + working_dir: None, + analysis: None, + }; + + // First run: the year is new, so the feed is downloaded and ingested. + let output = runner + .run_once_nvd( + (), + importer(&[2099], None, &nvd.uri()), + serde_json::Value::Null, + ) + .await?; + + assert_eq!(output.report.number_of_items, 1); + assert!( + output.report.messages.is_empty(), + "{:?}", + output.report.messages + ); + + let continuation = output.continuation.expect("continuation state"); + let state: HashMap = serde_json::from_value(continuation.clone())?; + assert_eq!( + state.get(&2099).map(String::as_str), + Some("0000000000000000000000000000000000000000000000000000000000000000") + ); + + // Second run with the returned continuation: the `.meta` sha256 is + // unchanged, so the year is skipped without re-ingesting. + let output = runner + .run_once_nvd( + (), + importer(&[2099], None, &nvd.uri()), + continuation.clone(), + ) + .await?; + + assert_eq!(output.report.number_of_items, 0); + assert!( + output.report.messages.is_empty(), + "{:?}", + output.report.messages + ); + assert_eq!(output.continuation, Some(continuation)); + + Ok(()) + } +} diff --git a/modules/ingestor/src/graph/advisory/cpe_status.rs b/modules/ingestor/src/graph/advisory/cpe_status.rs new file mode 100644 index 000000000..90470b241 --- /dev/null +++ b/modules/ingestor/src/graph/advisory/cpe_status.rs @@ -0,0 +1,72 @@ +use crate::graph::advisory::version::VersionInfo; +use trustify_common::cpe::Cpe; +use trustify_entity::{cpe_status, version_range}; +use uuid::Uuid; + +use sea_orm::Set; + +const NAMESPACE: Uuid = Uuid::from_bytes([ + 0x8f, 0x3a, 0x6c, 0x02, 0x4b, 0x1d, 0x4a, 0x7e, 0xb1, 0x0c, 0x2d, 0x9a, 0x77, 0x4e, 0x1f, 0x63, +]); + +/// A vulnerability status keyed by a CPE (vendor/product identity), mirroring +/// [`crate::graph::advisory::purl_status::PurlStatus`]. +/// +/// `cpe` carries the affected vendor/product identity with its version +/// component normalized to ANY (see [`Cpe::with_any_version`]); the actual +/// affected version(s) are expressed through `info` (a `version_range`). +#[derive(Debug, Eq, Hash, PartialEq, Clone)] +pub struct CpeStatus { + pub cpe: Cpe, + pub context_cpe: Option, + pub status: Uuid, + pub info: VersionInfo, +} + +impl CpeStatus { + pub fn new(cpe: Cpe, context_cpe: Option, status: Uuid, info: VersionInfo) -> Self { + Self { + cpe, + context_cpe, + status, + info, + } + } + + pub fn into_active_model( + self, + advisory_id: Uuid, + vulnerability_id: String, + ) -> (version_range::ActiveModel, cpe_status::ActiveModel) { + let cpe_id = self.cpe.uuid(); + let context_cpe_id = self.context_cpe.as_ref().map(Cpe::uuid); + + let version_range = self.info.clone().into_active_model(); + + let cpe_status = cpe_status::ActiveModel { + id: Set(self.uuid(advisory_id, vulnerability_id.clone())), + advisory_id: Set(advisory_id), + vulnerability_id: Set(vulnerability_id), + status_id: Set(self.status), + cpe_id: Set(cpe_id), + context_cpe_id: Set(context_cpe_id), + version_range_id: version_range.clone().id, + }; + + (version_range, cpe_status) + } + + pub fn uuid(&self, advisory_id: Uuid, vulnerability_id: String) -> Uuid { + let mut result = Uuid::new_v5(&NAMESPACE, self.status.as_bytes()); + result = Uuid::new_v5(&result, self.cpe.uuid().as_bytes()); + result = Uuid::new_v5(&result, self.info.uuid().as_bytes()); + result = Uuid::new_v5(&result, advisory_id.as_bytes()); + result = Uuid::new_v5(&result, vulnerability_id.as_bytes()); + + if let Some(cpe) = &self.context_cpe { + result = Uuid::new_v5(&result, cpe.uuid().as_bytes()) + } + + result + } +} diff --git a/modules/ingestor/src/graph/advisory/mod.rs b/modules/ingestor/src/graph/advisory/mod.rs index 6abd9edaf..c2b7716f9 100644 --- a/modules/ingestor/src/graph/advisory/mod.rs +++ b/modules/ingestor/src/graph/advisory/mod.rs @@ -22,6 +22,7 @@ use trustify_entity::{self as entity, advisory, labels::Labels, source_document} use uuid::Uuid; pub mod advisory_vulnerability; +pub mod cpe_status; pub mod product_status; pub mod purl_status; pub mod version; diff --git a/modules/ingestor/src/graph/cpe_status_creator.rs b/modules/ingestor/src/graph/cpe_status_creator.rs new file mode 100644 index 000000000..28d3f0aa3 --- /dev/null +++ b/modules/ingestor/src/graph/cpe_status_creator.rs @@ -0,0 +1,137 @@ +use crate::graph::{ + advisory::{cpe_status::CpeStatus, version::VersionInfo}, + error::Error, +}; +use sea_orm::{ActiveValue::Set, ConnectionTrait, EntityTrait, QueryFilter}; +use sea_query::{Expr, OnConflict, PgFunc}; +use std::collections::{BTreeMap, BTreeSet}; +use tracing::instrument; +use trustify_common::{cpe::Cpe, db::chunk::EntityChunkedIter}; +use trustify_entity::{cpe_status, status, version_range}; +use uuid::Uuid; + +/// Input data for creating a CPE status entry +#[derive(Clone, Debug)] +pub struct CpeStatusEntry { + pub advisory_id: Uuid, + pub vulnerability_id: String, + /// The vendor/product identity CPE. Its version component does not need + /// to be pre-normalized to ANY -- [`CpeStatusEntry`] callers are expected + /// to pass whatever CPE they have; normalization happens upstream in the + /// CVE loader via [`Cpe::with_any_version`]. + pub cpe: Cpe, + pub status: String, + pub version_info: VersionInfo, + pub context_cpe: Option, +} + +/// Creator for batch insertion of CPE statuses. +/// +/// Mirrors [`crate::graph::purl::status_creator::PurlStatusCreator`], keyed +/// by `cpe_id` instead of `base_purl_id`. +#[derive(Default)] +pub struct CpeStatusCreator { + entries: Vec, +} + +impl CpeStatusCreator { + pub fn new() -> Self { + Self::default() + } + + /// Add a CPE status entry to be created + pub fn add(&mut self, entry: CpeStatusEntry) { + self.entries.push(entry); + } + + /// Create all collected CPE statuses in batches + #[instrument(skip_all, fields(num = self.entries.len()), err(level=tracing::Level::INFO))] + pub async fn create(self, connection: &C) -> Result<(), Error> + where + C: ConnectionTrait, + { + if self.entries.is_empty() { + return Ok(()); + } + + // 1. Batch lookup all unique status slugs + let unique_statuses: Vec = self + .entries + .iter() + .map(|e| e.status.clone()) + .collect::>() + .into_iter() + .collect(); + + let status_models = status::Entity::find() + .filter(Expr::col(status::Column::Slug).eq(PgFunc::any(unique_statuses))) + .all(connection) + .await?; + + let status_map: BTreeMap = status_models + .into_iter() + .map(|s| (s.slug.clone(), s.id)) + .collect(); + + // 2. Deduplicate and build ActiveModels + let mut version_ranges = BTreeMap::new(); + let mut cpe_statuses = BTreeMap::new(); + + for entry in self.entries { + // Validate status exists + let status_id = *status_map + .get(&entry.status) + .ok_or_else(|| Error::InvalidStatus(entry.status.clone()))?; + + let cpe_status = CpeStatus { + cpe: entry.cpe.clone(), + context_cpe: entry.context_cpe.clone(), + status: status_id, + info: entry.version_info.clone(), + }; + + let uuid = cpe_status.uuid(entry.advisory_id, entry.vulnerability_id.clone()); + let cpe_id = entry.cpe.uuid(); + let version_range_id = entry.version_info.uuid(); + let context_cpe_id = entry.context_cpe.as_ref().map(|cpe| cpe.uuid()); + + // Deduplicate version ranges + version_ranges + .entry(version_range_id) + .or_insert_with(|| entry.version_info.clone().into_active_model()); + + // Deduplicate cpe_statuses by UUID + cpe_statuses + .entry(uuid) + .or_insert_with(|| cpe_status::ActiveModel { + id: Set(uuid), + advisory_id: Set(entry.advisory_id), + vulnerability_id: Set(entry.vulnerability_id.clone()), + status_id: Set(status_id), + cpe_id: Set(cpe_id), + version_range_id: Set(version_range_id), + context_cpe_id: Set(context_cpe_id), + }); + } + + // 3. Batch insert version ranges + for batch in &version_ranges.into_values().chunked() { + version_range::Entity::insert_many(batch) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .do_nothing() + .exec_without_returning(connection) + .await?; + } + + // 4. Batch insert cpe_statuses + for batch in &cpe_statuses.into_values().chunked() { + cpe_status::Entity::insert_many(batch) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .do_nothing() + .exec_without_returning(connection) + .await?; + } + + Ok(()) + } +} diff --git a/modules/ingestor/src/graph/mod.rs b/modules/ingestor/src/graph/mod.rs index 6c28c5345..93c30655b 100644 --- a/modules/ingestor/src/graph/mod.rs +++ b/modules/ingestor/src/graph/mod.rs @@ -1,5 +1,6 @@ pub mod advisory; pub mod cpe; +pub mod cpe_status_creator; pub mod cvss; pub mod db_context; pub mod error; diff --git a/modules/ingestor/src/graph/sbom/spdx.rs b/modules/ingestor/src/graph/sbom/spdx.rs index df89be84f..dace0fcd0 100644 --- a/modules/ingestor/src/graph/sbom/spdx.rs +++ b/modules/ingestor/src/graph/sbom/spdx.rs @@ -238,7 +238,7 @@ impl SbomContext { log::info!("Failed to parse PURL ({}): {err}", r.reference_locator); } }, - "cpe22Type" => match Cpe::from_str(&r.reference_locator) { + "cpe22Type" | "cpe23Type" => match Cpe::from_str(&r.reference_locator) { Ok(cpe) => { refs.push(PackageReference::Cpe(cpe.uuid())); cpes.add(cpe.clone()); diff --git a/modules/ingestor/src/service/advisory/cve/loader.rs b/modules/ingestor/src/service/advisory/cve/loader.rs index f3b3e1431..3ea888a01 100644 --- a/modules/ingestor/src/service/advisory/cve/loader.rs +++ b/modules/ingestor/src/service/advisory/cve/loader.rs @@ -6,6 +6,8 @@ use crate::{ AdvisoryInformation, AdvisoryVulnerabilityInformation, version::{Version, VersionInfo, VersionSpec}, }, + cpe::CpeCreator, + cpe_status_creator::{CpeStatusCreator, CpeStatusEntry}, cvss::ScoreCreator, purl::{ self, @@ -30,7 +32,7 @@ use std::str::FromStr; use std::{collections::HashSet, fmt::Debug}; use time::OffsetDateTime; use tracing::instrument; -use trustify_common::hashing::Digests; +use trustify_common::{cpe::Cpe, hashing::Digests}; use trustify_entity::advisory_vulnerability_score::{ScoreType, Severity}; use trustify_entity::{labels::Labels, version_scheme::VersionScheme, vulnerability}; @@ -131,58 +133,73 @@ impl<'g> CveLoader<'g> { .exec(tx) .await?; - // Initialize batch creator for efficient status ingestion + // Initialize batch creators for efficient status ingestion let mut purl_status_creator = PurlStatusCreator::new(); + let mut cpe_status_creator = CpeStatusCreator::new(); let mut base_purls = HashSet::new(); + let mut cpes = HashSet::new(); + + for product in affected { + if let Some(purl) = divine_purl(product) { + // Collect base PURL for batch creation + base_purls.insert(purl.clone()); + + // okay! we have a purl, now + // sort out version bounds & status + for version in &product.versions { + let (version_spec, version_type, status) = version_spec_and_status(version); + + // Add package status entry to batch creator + purl_status_creator.add(PurlStatusEntry { + advisory_id: advisory_vuln.advisory.advisory.id, + vulnerability_id: advisory_vuln + .advisory_vulnerability + .vulnerability_id + .clone(), + purl: purl.clone(), + status: status_slug(status), + version_info: VersionInfo { + scheme: version_type + .as_deref() + .map(VersionScheme::from) + .unwrap_or(VersionScheme::Generic), + spec: version_spec, + }, + context_cpe: None, + }); + } + } - if let Some(affected) = affected { - for product in affected { - if let Some(purl) = divine_purl(product) { - // Collect base PURL for batch creation - base_purls.insert(purl.clone()); - - // okay! we have a purl, now - // sort out version bounds & status + // CPE-keyed applicability: every parseable CPE in `cpes` is + // stored as a vendor/product identity (version normalized to + // ANY), with the affected version(s) expressed via + // version_range, mirroring the purl path above. + for cpe_str in &product.cpes { + let Ok(cpe) = Cpe::from_str(cpe_str) else { + continue; + }; + let identity_cpe = cpe.with_any_version(); + + if !product.versions.is_empty() { for version in &product.versions { - let (version_spec, version_type, status) = match version { - cve::common::Version::Single(version) => ( - VersionSpec::Exact(version.version.clone()), - version.version_type.clone(), - &version.status, - ), - cve::common::Version::Range(range) => match &range.range { - VersionRange::LessThan(upper) => ( - VersionSpec::Range( - Version::Inclusive(range.version.clone()), - Version::Exclusive(upper.clone()), - ), - Some(range.version_type.clone()), - &range.status, - ), - VersionRange::LessThanOrEqual(upper) => ( - VersionSpec::Range( - Version::Inclusive(range.version.clone()), - Version::Inclusive(upper.clone()), - ), - Some(range.version_type.clone()), - &range.status, - ), - }, - }; + let (version_spec, version_type, status) = version_spec_and_status(version); + + // `unknown` has no row in the `status` table and + // carries no applicability information; skip it + // instead of failing the whole document. + if matches!(status, Status::Unknown) { + continue; + } - // Add package status entry to batch creator - purl_status_creator.add(PurlStatusEntry { + cpes.insert(identity_cpe.clone()); + cpe_status_creator.add(CpeStatusEntry { advisory_id: advisory_vuln.advisory.advisory.id, vulnerability_id: advisory_vuln .advisory_vulnerability .vulnerability_id .clone(), - purl: purl.clone(), - status: match status { - Status::Affected => "affected".to_string(), - Status::Unaffected => "not_affected".to_string(), - Status::Unknown => "unknown".to_string(), - }, + cpe: identity_cpe.clone(), + status: status_slug(status), version_info: VersionInfo { scheme: version_type .as_deref() @@ -193,6 +210,34 @@ impl<'g> CveLoader<'g> { context_cpe: None, }); } + } else if let trustify_common::cpe::Component::Value(version) = cpe.version() { + // no explicit versions list: fall back to the concrete + // version carried by the CPE itself. + let status = product.default_status.clone().unwrap_or(Status::Unknown); + + // `unknown` (also the fallback for a missing + // `defaultStatus`) has no row in the `status` table and + // carries no applicability information; skip it instead + // of failing the whole document. + if matches!(status, Status::Unknown) { + continue; + } + + cpes.insert(identity_cpe.clone()); + cpe_status_creator.add(CpeStatusEntry { + advisory_id: advisory_vuln.advisory.advisory.id, + vulnerability_id: advisory_vuln + .advisory_vulnerability + .vulnerability_id + .clone(), + cpe: identity_cpe.clone(), + status: status_slug(&status), + version_info: VersionInfo { + scheme: VersionScheme::Generic, + spec: VersionSpec::Exact(version), + }, + context_cpe: None, + }); } } } @@ -200,8 +245,16 @@ impl<'g> CveLoader<'g> { // Batch create base PURLs (without versions/qualifiers) purl::batch_create_base_purls(base_purls, tx).await?; + // Batch create CPEs (vendor/product identity, version normalized to ANY) + let mut cpe_creator = CpeCreator::new(); + for cpe in cpes { + cpe_creator.add(cpe); + } + cpe_creator.create(tx).await?; + // Batch create statuses purl_status_creator.create(tx).await?; + cpe_status_creator.create(tx).await?; // Manage vulnerability descriptions without needing to query the vulnerability Graph::drop_vulnerability_descriptions_for_advisory(advisory.advisory.id, tx).await?; @@ -259,7 +312,7 @@ impl<'g> CveLoader<'g> { .provider_metadata .short_name .as_deref(), - None, + Vec::new(), ), Cve::Published(published) => ( published @@ -298,7 +351,23 @@ impl<'g> CveLoader<'g> { .provider_metadata .short_name .as_deref(), - Some(&published.containers.cna.affected), + // CNA-reported affected entries are the primary source; ADP + // containers (e.g. CISA vulnrichment) supplement them with + // additional CPE/version data, particularly for older CVEs + // the CNA never annotated with CPEs. + published + .containers + .cna + .affected + .iter() + .chain( + published + .containers + .adp + .iter() + .flat_map(|adp| adp.affected.iter()), + ) + .collect(), ), }; @@ -387,6 +456,49 @@ fn get_score(cvss: &Value) -> Option<(ScoreType, f64, Severity)> { } } +/// Maps a CVE 5.x `versions[]` entry to its version bound and CVE status. +/// +/// Shared by both the purl and CPE ingestion paths so that any future change +/// to the version-range mapping stays consistent between the two. +fn version_spec_and_status( + version: &cve::common::Version, +) -> (VersionSpec, Option, &Status) { + match version { + cve::common::Version::Single(version) => ( + VersionSpec::Exact(version.version.clone()), + version.version_type.clone(), + &version.status, + ), + cve::common::Version::Range(range) => match &range.range { + VersionRange::LessThan(upper) => ( + VersionSpec::Range( + Version::Inclusive(range.version.clone()), + Version::Exclusive(upper.clone()), + ), + Some(range.version_type.clone()), + &range.status, + ), + VersionRange::LessThanOrEqual(upper) => ( + VersionSpec::Range( + Version::Inclusive(range.version.clone()), + Version::Inclusive(upper.clone()), + ), + Some(range.version_type.clone()), + &range.status, + ), + }, + } +} + +/// Maps a CVE 5.x affected-version status to the trustify status slug. +fn status_slug(status: &Status) -> String { + match status { + Status::Affected => "affected".to_string(), + Status::Unaffected => "not_affected".to_string(), + Status::Unknown => "unknown".to_string(), + } +} + /// Extracts the best score from a single metric, preferring higher CVSS versions. fn score_from_metric(metric: &cve::published::Metric) -> Option { metric @@ -412,7 +524,7 @@ struct VulnerabilityDetails<'a> { pub org_name: Option<&'a str>, pub descriptions: &'a Vec, pub assigned: Option, - pub affected: Option<&'a Vec>, + pub affected: Vec<&'a Product>, pub information: VulnerabilityInformation, } @@ -675,4 +787,173 @@ mod test { Ok(()) } + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn cve_loader_stores_cpe_status(ctx: &TrustifyContext) -> Result<(), anyhow::Error> { + use trustify_entity::{cpe as cpe_entity, cpe_status, status, version_range}; + + let graph = Graph::new(); + + let (cve, digests): (Cve, _) = document("cve/CVE-2099-0001.json").await?; + + let loader = CveLoader::new(&graph); + ctx.db + .transaction(async |tx| { + loader + .load(("file", "CVE-2099-0001.json"), cve.clone(), &digests, tx) + .await + }) + .await?; + + let advisory = graph + .get_advisory_by_digest(&digests.sha256.encode_hex::(), &ctx.db) + .await? + .expect("advisory must be ingested"); + + // Join cpe_status -> cpe -> status -> version_range so we can assert + // on human-readable vendor/product/status/version_range values. + let rows = cpe_status::Entity::find() + .filter(cpe_status::Column::AdvisoryId.eq(advisory.advisory.id)) + .all(&ctx.db) + .await?; + + // openssl: 1 exact version + 1 range = 2 rows; busybox: no `versions` + // list, falls back to the concrete CPE version = 1 row. Total 3. + assert_eq!(rows.len(), 3, "expected 3 cpe_status rows, got {rows:?}"); + + let mut by_vendor: std::collections::HashMap> = + std::collections::HashMap::new(); + for row in rows { + let cpe = cpe_entity::Entity::find_by_id(row.cpe_id) + .one(&ctx.db) + .await? + .expect("referenced cpe must exist"); + by_vendor + .entry(cpe.vendor.clone().unwrap_or_default()) + .or_default() + .push(row); + + // the identity cpe is version-normalized to ANY + assert_eq!( + cpe.version.as_deref(), + Some("*"), + "cpe_status.cpe_id must be version-ANY" + ); + } + + let openssl_rows = by_vendor.get("openssl").expect("openssl rows"); + assert_eq!(openssl_rows.len(), 2); + for row in openssl_rows { + let st = status::Entity::find_by_id(row.status_id) + .one(&ctx.db) + .await? + .expect("status must exist"); + assert_eq!(st.slug, "affected"); + + let vr = version_range::Entity::find_by_id(row.version_range_id) + .one(&ctx.db) + .await? + .expect("version_range must exist"); + assert!( + vr.low_version.as_deref() == Some("0.9.8w") + || vr.low_version.as_deref() == Some("1.0.0"), + "unexpected version_range: {vr:?}" + ); + } + + let busybox_rows = by_vendor.get("busybox").expect("busybox rows"); + assert_eq!(busybox_rows.len(), 1); + let busybox_row = &busybox_rows[0]; + let st = status::Entity::find_by_id(busybox_row.status_id) + .one(&ctx.db) + .await? + .expect("status must exist"); + assert_eq!(st.slug, "affected"); + let vr = version_range::Entity::find_by_id(busybox_row.version_range_id) + .one(&ctx.db) + .await? + .expect("version_range must exist"); + assert_eq!(vr.low_version.as_deref(), Some("1.19.4")); + + // Re-ingest idempotency: loading the same document again must not + // create duplicate cpe_status rows (deterministic v5 UUIDs). + ctx.db + .transaction(async |tx| { + loader + .load(("file", "CVE-2099-0001.json"), cve, &digests, tx) + .await + }) + .await?; + + let rows_after_reingest = cpe_status::Entity::find() + .filter(cpe_status::Column::AdvisoryId.eq(advisory.advisory.id)) + .all(&ctx.db) + .await?; + assert_eq!( + rows_after_reingest.len(), + 3, + "re-ingest must not duplicate rows" + ); + + Ok(()) + } + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn cve_loader_stores_cpe_status_from_adp_container( + ctx: &TrustifyContext, + ) -> Result<(), anyhow::Error> { + use trustify_entity::{cpe as cpe_entity, cpe_status, status, version_range}; + + let graph = Graph::new(); + + // cna.affected is empty; the ADP (vulnrichment-style) container is + // the only source of CPE/version data for this record. + let (cve, digests): (Cve, _) = document("cve/CVE-2099-0002.json").await?; + + let loader = CveLoader::new(&graph); + ctx.db + .transaction(async |tx| { + loader + .load(("file", "CVE-2099-0002.json"), cve, &digests, tx) + .await + }) + .await?; + + let advisory = graph + .get_advisory_by_digest(&digests.sha256.encode_hex::(), &ctx.db) + .await? + .expect("advisory must be ingested"); + + let rows = cpe_status::Entity::find() + .filter(cpe_status::Column::AdvisoryId.eq(advisory.advisory.id)) + .all(&ctx.db) + .await?; + + assert_eq!(rows.len(), 1, "expected 1 cpe_status row, got {rows:?}"); + + let row = &rows[0]; + let cpe = cpe_entity::Entity::find_by_id(row.cpe_id) + .one(&ctx.db) + .await? + .expect("referenced cpe must exist"); + assert_eq!(cpe.vendor.as_deref(), Some("denx")); + assert_eq!(cpe.product.as_deref(), Some("u-boot")); + assert_eq!(cpe.version.as_deref(), Some("*")); + + let st = status::Entity::find_by_id(row.status_id) + .one(&ctx.db) + .await? + .expect("status must exist"); + assert_eq!(st.slug, "affected"); + + let vr = version_range::Entity::find_by_id(row.version_range_id) + .one(&ctx.db) + .await? + .expect("version_range must exist"); + assert_eq!(vr.low_version.as_deref(), Some("2019.04")); + + Ok(()) + } } diff --git a/modules/ingestor/src/service/advisory/mod.rs b/modules/ingestor/src/service/advisory/mod.rs index 53607585c..f3045deaf 100644 --- a/modules/ingestor/src/service/advisory/mod.rs +++ b/modules/ingestor/src/service/advisory/mod.rs @@ -1,5 +1,6 @@ pub mod csaf; pub mod cve; +pub mod nvd; pub mod osv; #[cfg(test)] diff --git a/modules/ingestor/src/service/advisory/nvd/loader.rs b/modules/ingestor/src/service/advisory/nvd/loader.rs new file mode 100644 index 000000000..127e8ba00 --- /dev/null +++ b/modules/ingestor/src/service/advisory/nvd/loader.rs @@ -0,0 +1,431 @@ +use crate::{ + graph::{ + Graph, + advisory::{ + AdvisoryInformation, AdvisoryVulnerabilityInformation, + version::{Version, VersionInfo, VersionSpec}, + }, + cpe::CpeCreator, + cpe_status_creator::{CpeStatusCreator, CpeStatusEntry}, + cvss::{ScoreCreator, ScoreInformation}, + vulnerability::{BaseScore, VulnerabilityInformation, creator::VulnerabilityCreator}, + }, + model::IngestResult, + service::{Error, Warnings, advisory::nvd::schema::*}, +}; +use cvss::{v2_0::CvssV2, v3::CvssV3, v4_0::CvssV4}; +use sea_orm::{ConnectionTrait, TransactionTrait}; +use std::collections::HashSet; +use std::fmt::Debug; +use std::str::FromStr; +use time::{ + OffsetDateTime, PrimitiveDateTime, format_description::well_known::Rfc3339, + macros::format_description, +}; +use tracing::instrument; +use trustify_common::{ + cpe::{Component, Cpe}, + hashing::Digests, +}; +use trustify_entity::{labels::Labels, version_scheme::VersionScheme}; + +/// The publisher recorded for NVD-sourced advisories. +const NVD_ISSUER: &str = "NVD"; + +/// Loader capable of parsing an NVD CVE API 2.0 `cve` record (as republished by the +/// `fkie-cad/nvd-json-data-feeds` mirror) and integrating it into the knowledge base. +/// +/// NVD is a supplementary advisory source: its records are stored as their own +/// advisory, linked to the same vulnerability as any CVE-List / CSAF / OSV record. +/// Its value is the rich CPE applicability data (`configurations`), which is mapped +/// to `cpe_status` rows keyed by vendor/product identity plus an affected +/// `version_range`. Unlike the CVE-List path, NVD ranges are stored with the +/// [`VersionScheme::Semver`] scheme so ordered range comparison (not just exact +/// equality) is used at match time. +pub struct NvdLoader<'g> { + graph: &'g Graph, +} + +impl<'g> NvdLoader<'g> { + pub fn new(graph: &'g Graph) -> Self { + Self { graph } + } + + #[instrument(skip(self, cve, tx), err(level=tracing::Level::INFO))] + pub async fn load( + &self, + labels: impl Into + Debug, + cve: NvdCve, + digests: &Digests, + tx: &(impl ConnectionTrait + TransactionTrait), + ) -> Result { + let warnings = Warnings::new(); + let id = cve.id.clone(); + let labels = labels.into().add("type", "nvd"); + + let published = cve.published.as_deref().and_then(parse_timestamp); + let modified = cve.last_modified.as_deref().and_then(parse_timestamp); + + let cwes = extract_cwes(&cve.weaknesses); + let title = None; + let english_description = find_english_description(&cve.descriptions).map(str::to_string); + + // CVSS scores (parsed from the canonical vector strings). + let scores = extract_scores(&id, &cve.metrics); + let base_score = best_base_score(&scores); + + // Upsert the vulnerability. NVD does not claim to be the authoritative + // advisory for a CVE, so we deliberately do not touch + // `authoritative_advisory_id` (that is owned by the CVE-List path). + let mut vuln_creator = VulnerabilityCreator::new(); + vuln_creator.add( + &id, + VulnerabilityInformation { + title: title.clone(), + reserved: None, + published, + modified, + withdrawn: None, + cwes: cwes.clone(), + base_score, + }, + ); + vuln_creator.create(tx).await?; + + let advisory_info = AdvisoryInformation { + id: id.clone(), + title: title.clone(), + version: None, + issuer: Some(NVD_ISSUER.to_string()), + published, + modified, + withdrawn: None, + }; + + let advisory = self + .graph + .ingest_advisory(&id, labels, digests, advisory_info, tx) + .await?; + + let advisory_vuln = advisory + .link_to_vulnerability( + &id, + Some(AdvisoryVulnerabilityInformation { + title, + summary: None, + description: english_description.clone(), + reserved_date: None, + discovery_date: None, + release_date: published, + cwes, + }), + tx, + ) + .await?; + + let mut score_creator = ScoreCreator::new(advisory.advisory.id); + score_creator.extend(scores); + score_creator.create(tx).await?; + + // CPE applicability -> cpe_status. + let mut cpe_status_creator = CpeStatusCreator::new(); + let mut cpes = HashSet::new(); + + for cpe_match in cve + .configurations + .iter() + .flat_map(|c| c.nodes.iter()) + .flat_map(|n| n.cpe_match.iter()) + { + if !cpe_match.vulnerable { + continue; + } + + let cpe = match Cpe::from_str(&cpe_match.criteria) { + Ok(cpe) => cpe, + Err(err) => { + let msg = format!( + "{id}: dropping unparseable CPE criteria {:?}: {err}", + cpe_match.criteria + ); + tracing::warn!("{msg}"); + warnings.add(msg); + continue; + } + }; + let identity_cpe = cpe.with_any_version(); + + let Some(spec) = version_spec(cpe_match, &cpe) else { + // No version bounds and no concrete version in the criteria (an + // "all versions" statement). Ordered range matching can't express + // an unbounded-both-sides range, so this is not modeled yet. + tracing::debug!( + vulnerability = id, + criteria = cpe_match.criteria, + "skipping unbounded 'all versions' CPE match" + ); + continue; + }; + + cpes.insert(identity_cpe.clone()); + cpe_status_creator.add(CpeStatusEntry { + advisory_id: advisory_vuln.advisory.advisory.id, + vulnerability_id: advisory_vuln + .advisory_vulnerability + .vulnerability_id + .clone(), + cpe: identity_cpe, + status: "affected".to_string(), + version_info: VersionInfo { + // NVD version bounds are ordered comparisons; use the semver + // scheme so `version_matches` does range (not exact) matching. + scheme: VersionScheme::Semver, + spec, + }, + context_cpe: None, + }); + } + + let mut cpe_creator = CpeCreator::new(); + for cpe in cpes { + cpe_creator.add(cpe); + } + cpe_creator.create(tx).await?; + cpe_status_creator.create(tx).await?; + + // Manage vulnerability descriptions. + let entries = build_descriptions(&cve.descriptions); + Graph::drop_vulnerability_descriptions_for_advisory(advisory.advisory.id, tx).await?; + Graph::add_vulnerability_descriptions(&id, advisory.advisory.id, entries, tx).await?; + + Ok(IngestResult { + id: advisory.advisory.id.to_string(), + document_id: Some(id), + warnings: warnings.into(), + }) + } +} + +/// Derives the affected version bound for a single `cpeMatch`. +/// +/// Returns `None` when the entry carries neither explicit version bounds nor a +/// concrete version in its CPE criteria (i.e. an unbounded "all versions" match). +fn version_spec(cpe_match: &CpeMatch, cpe: &Cpe) -> Option { + let has_bounds = cpe_match.version_start_including.is_some() + || cpe_match.version_start_excluding.is_some() + || cpe_match.version_end_including.is_some() + || cpe_match.version_end_excluding.is_some(); + + if has_bounds { + let low = match ( + &cpe_match.version_start_including, + &cpe_match.version_start_excluding, + ) { + (Some(v), _) => Version::Inclusive(v.clone()), + (_, Some(v)) => Version::Exclusive(v.clone()), + _ => Version::Unbounded, + }; + let high = match ( + &cpe_match.version_end_including, + &cpe_match.version_end_excluding, + ) { + (Some(v), _) => Version::Inclusive(v.clone()), + (_, Some(v)) => Version::Exclusive(v.clone()), + _ => Version::Unbounded, + }; + Some(VersionSpec::Range(low, high)) + } else if let Component::Value(version) = cpe.version() { + Some(VersionSpec::Exact(version)) + } else { + None + } +} + +/// Parses all CVSS vectors present in an NVD `metrics` object into [`ScoreInformation`], +/// dispatching per version bucket to the appropriate `cvss-rs` parser. +fn extract_scores(vulnerability_id: &str, metrics: &Metrics) -> Vec { + let mut scores = Vec::new(); + + for m in &metrics.cvss_metric_v40 { + if let Ok(cvss) = CvssV4::from_str(&m.cvss_data.vector_string) { + scores.push((vulnerability_id.to_string(), cvss).into()); + } + } + // v3.0 and v3.1 share the same parser; the concrete version is read from the vector. + for m in metrics + .cvss_metric_v31 + .iter() + .chain(&metrics.cvss_metric_v30) + { + if let Ok(cvss) = CvssV3::from_str(&m.cvss_data.vector_string) { + scores.push((vulnerability_id.to_string(), cvss).into()); + } + } + for m in &metrics.cvss_metric_v2 { + if let Ok(cvss) = CvssV2::from_str(&m.cvss_data.vector_string) { + scores.push((vulnerability_id.to_string(), cvss).into()); + } + } + + scores +} + +/// Picks the "best" base score to represent the vulnerability: the highest score type +/// (newest CVSS version), and within a type the highest numeric score. +fn best_base_score(scores: &[ScoreInformation]) -> Option { + scores + .iter() + .max_by(|a, b| { + a.r#type.cmp(&b.r#type).then( + a.score + .partial_cmp(&b.score) + .unwrap_or(std::cmp::Ordering::Equal), + ) + }) + .map(|s| BaseScore { + r#type: s.r#type, + score: s.score as f64, + severity: s.severity, + }) +} + +/// Collects `CWE-` identifiers from NVD `weaknesses`, ignoring the placeholder +/// `NVD-CWE-noinfo` / `NVD-CWE-Other` markers. Returns `None` if none are present. +fn extract_cwes(weaknesses: &[Weakness]) -> Option> { + let cwes: Vec = weaknesses + .iter() + .flat_map(|w| w.description.iter()) + .map(|d| d.value.clone()) + .filter(|v| v.starts_with("CWE-")) + .collect(); + + if cwes.is_empty() { None } else { Some(cwes) } +} + +fn find_english_description(descriptions: &[LangString]) -> Option<&str> { + descriptions + .iter() + .find(|d| matches!(d.lang.as_str(), "en" | "en-US" | "en_US")) + .map(|d| d.value.as_str()) +} + +fn build_descriptions(descriptions: &[LangString]) -> Vec<(&str, &str)> { + descriptions + .iter() + .map(|d| (d.lang.as_str(), d.value.as_str())) + .collect() +} + +/// Parses an NVD timestamp. NVD emits UTC timestamps without an offset +/// (e.g. `2024-02-15T10:30:00.000`); RFC 3339 is also accepted defensively. +fn parse_timestamp(s: &str) -> Option { + if let Ok(dt) = OffsetDateTime::parse(s, &Rfc3339) { + return Some(dt); + } + let with_sub = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]"); + if let Ok(dt) = PrimitiveDateTime::parse(s, with_sub) { + return Some(dt.assume_utc()); + } + let no_sub = format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"); + PrimitiveDateTime::parse(s, no_sub) + .ok() + .map(|dt| dt.assume_utc()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::graph::Graph; + use hex::ToHex; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + use test_context::test_context; + use test_log::test; + use time::macros::datetime; + use trustify_entity::{cpe as cpe_entity, cpe_status, status, version_range, version_scheme}; + use trustify_test_context::{TrustifyContext, document}; + + #[test_context(TrustifyContext)] + #[test(tokio::test)] + async fn nvd_loader_stores_semver_range_cpe_status( + ctx: &TrustifyContext, + ) -> Result<(), anyhow::Error> { + let graph = Graph::new(); + + let (cve, digests): (NvdCve, _) = document("nvd/CVE-2099-1000.json").await?; + + let loader = NvdLoader::new(&graph); + ctx.db + .transaction(async |tx| { + loader + .load(("file", "CVE-2099-1000.json"), cve.clone(), &digests, tx) + .await + }) + .await?; + + // Vulnerability + advisory were created. + let vuln = graph.get_vulnerability("CVE-2099-1000", &ctx.db).await?; + assert!(vuln.is_some(), "vulnerability must be ingested"); + assert_eq!( + vuln.unwrap().vulnerability.published, + Some(datetime!(2099-01-02 10:00:00 UTC)), + "published timestamp must parse from the NVD naive format" + ); + + let advisory = graph + .get_advisory_by_digest(&digests.sha256.encode_hex::(), &ctx.db) + .await? + .expect("advisory must be ingested"); + + // Exactly one cpe_status row: the vulnerable application match. The + // `vulnerable: false` OS entry must be skipped. + let rows = cpe_status::Entity::find() + .filter(cpe_status::Column::AdvisoryId.eq(advisory.advisory.id)) + .all(&ctx.db) + .await?; + assert_eq!(rows.len(), 1, "expected 1 cpe_status row, got {rows:?}"); + let row = &rows[0]; + + // Identity CPE is version-normalized to ANY. + let cpe = cpe_entity::Entity::find_by_id(row.cpe_id) + .one(&ctx.db) + .await? + .expect("referenced cpe must exist"); + assert_eq!(cpe.vendor.as_deref(), Some("example")); + assert_eq!(cpe.product.as_deref(), Some("widget")); + assert_eq!(cpe.version.as_deref(), Some("*")); + + let st = status::Entity::find_by_id(row.status_id) + .one(&ctx.db) + .await? + .expect("status must exist"); + assert_eq!(st.slug, "affected"); + + // The critical part: range stored under the `semver` scheme with + // inclusive-low / exclusive-high bounds so ordered range matching works. + let vr = version_range::Entity::find_by_id(row.version_range_id) + .one(&ctx.db) + .await? + .expect("version_range must exist"); + assert_eq!(vr.version_scheme_id, version_scheme::VersionScheme::Semver); + assert_eq!(vr.low_version.as_deref(), Some("1.0.0")); + assert_eq!(vr.low_inclusive, Some(true)); + assert_eq!(vr.high_version.as_deref(), Some("2.0.0")); + assert_eq!(vr.high_inclusive, Some(false)); + + // Re-ingest must be idempotent (deterministic v5 UUIDs). + ctx.db + .transaction(async |tx| { + loader + .load(("file", "CVE-2099-1000.json"), cve, &digests, tx) + .await + }) + .await?; + let rows_after = cpe_status::Entity::find() + .filter(cpe_status::Column::AdvisoryId.eq(advisory.advisory.id)) + .all(&ctx.db) + .await?; + assert_eq!(rows_after.len(), 1, "re-ingest must not duplicate rows"); + + Ok(()) + } +} diff --git a/modules/ingestor/src/service/advisory/nvd/mod.rs b/modules/ingestor/src/service/advisory/nvd/mod.rs new file mode 100644 index 000000000..3c5004635 --- /dev/null +++ b/modules/ingestor/src/service/advisory/nvd/mod.rs @@ -0,0 +1,2 @@ +pub mod loader; +pub mod schema; diff --git a/modules/ingestor/src/service/advisory/nvd/schema.rs b/modules/ingestor/src/service/advisory/nvd/schema.rs new file mode 100644 index 000000000..5febb1196 --- /dev/null +++ b/modules/ingestor/src/service/advisory/nvd/schema.rs @@ -0,0 +1,110 @@ +//! Minimal serde model for the subset of the NVD CVE API 2.0 `cve` object we consume. +//! +//! The `fkie-cad/nvd-json-data-feeds` mirror stores each CVE as a bare `cve` object +//! (the value of `vulnerabilities[].cve` from the NVD API), so no response wrapper is +//! modeled here. Only the fields trustify actually maps are declared; everything else +//! is ignored. + +use serde::Deserialize; + +/// A per-year feed file as published in the mirror's release assets +/// (`CVE-.json.xz`): a small envelope around the list of bare NVD `cve` +/// objects. Each item is kept as a raw JSON fragment so its original bytes can +/// be handed straight to the ingestor (which re-parses it as [`NvdCve`]). +#[derive(Debug, Deserialize)] +pub struct NvdYearFeed { + #[serde(default)] + pub cve_items: Vec>, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NvdCve { + pub id: String, + #[serde(default)] + pub source_identifier: Option, + #[serde(default)] + pub published: Option, + #[serde(default)] + pub last_modified: Option, + #[serde(default)] + pub vuln_status: Option, + #[serde(default)] + pub descriptions: Vec, + #[serde(default)] + pub metrics: Metrics, + #[serde(default)] + pub weaknesses: Vec, + #[serde(default)] + pub configurations: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct LangString { + pub lang: String, + pub value: String, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Metrics { + #[serde(default)] + pub cvss_metric_v40: Vec, + #[serde(default)] + pub cvss_metric_v31: Vec, + #[serde(default)] + pub cvss_metric_v30: Vec, + #[serde(default)] + pub cvss_metric_v2: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CvssMetric { + pub cvss_data: CvssData, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CvssData { + #[serde(default)] + pub version: String, + /// The canonical CVSS vector string (e.g. `CVSS:3.1/AV:N/...`). This is the + /// authoritative source we re-parse; the numeric `baseScore` is recomputed. + pub vector_string: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Weakness { + #[serde(default)] + pub description: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Configuration { + #[serde(default)] + pub nodes: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Node { + #[serde(default)] + pub cpe_match: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CpeMatch { + pub vulnerable: bool, + pub criteria: String, + #[serde(default)] + pub version_start_including: Option, + #[serde(default)] + pub version_start_excluding: Option, + #[serde(default)] + pub version_end_including: Option, + #[serde(default)] + pub version_end_excluding: Option, +} diff --git a/modules/ingestor/src/service/detect.rs b/modules/ingestor/src/service/detect.rs index 520dd9422..731d9b460 100644 --- a/modules/ingestor/src/service/detect.rs +++ b/modules/ingestor/src/service/detect.rs @@ -3,7 +3,10 @@ use crate::{ model::IngestResult, service::{ Error, JsonSource, - advisory::{csaf::loader::CsafLoader, cve::loader::CveLoader, osv::loader::OsvLoader}, + advisory::{ + csaf::loader::CsafLoader, cve::loader::CveLoader, nvd::loader::NvdLoader, + nvd::schema::NvdCve, osv::loader::OsvLoader, + }, sbom::{ clearly_defined::ClearlyDefinedLoader, clearly_defined_curation::ClearlyDefinedCurationLoader, cyclonedx::CyclonedxLoader, @@ -42,6 +45,10 @@ pub enum WireFormat { pub enum DetectedDocument { Csaf(Box), Cve(Box), + /// A bare NVD CVE API `cve` object. NVD is never content-detected (it is + /// indistinguishable from OSV by sniffing), so it is only produced when + /// `Format::NVD` is passed explicitly as the hint. + Nvd(Box), Osv(Box), /// SPDX keeps the raw Value because the loader applies license fixups before ingestion. Spdx(serde_json::Value), @@ -159,6 +166,9 @@ impl DocumentDetector { DetectedDocument::Cve(cve) => { CveLoader::new(graph).load(labels, *cve, digests, tx).await } + DetectedDocument::Nvd(cve) => { + NvdLoader::new(graph).load(labels, *cve, digests, tx).await + } DetectedDocument::Osv(osv) => { OsvLoader::new(graph) .load(labels, *osv, digests, issuer, tx) @@ -298,6 +308,9 @@ fn parse_format(source: impl JsonSource, format: Format) -> Result Ok(DetectedDocument::Cve(Box::new( source.parse_json().map_err(map_err)?, ))), + Format::NVD => Ok(DetectedDocument::Nvd(Box::new( + source.parse_json().map_err(map_err)?, + ))), Format::OSV => Ok(DetectedDocument::Osv(Box::new( source.parse_json().map_err(map_err)?, ))), diff --git a/modules/ingestor/src/service/format.rs b/modules/ingestor/src/service/format.rs index b0b511c88..efc990e11 100644 --- a/modules/ingestor/src/service/format.rs +++ b/modules/ingestor/src/service/format.rs @@ -19,6 +19,7 @@ pub enum Format { OSV, CSAF, CVE, + NVD, SPDX, CycloneDX, ClearlyDefinedCuration, diff --git a/openapi.yaml b/openapi.yaml index 653e46cfe..a7992948b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -322,6 +322,7 @@ paths: - osv - csaf - cve + - nvd - spdx - cyclonedx - clearlydefinedcuration @@ -2728,6 +2729,7 @@ paths: - osv - csaf - cve + - nvd - spdx - cyclonedx - clearlydefinedcuration @@ -4689,6 +4691,7 @@ components: - osv - csaf - cve + - nvd - spdx - cyclonedx - clearlydefinedcuration @@ -4803,6 +4806,12 @@ components: properties: cve: $ref: '#/components/schemas/CveImporter' + - type: object + required: + - nvd + properties: + nvd: + $ref: '#/components/schemas/NvdImporter' - type: object required: - clearlyDefined @@ -5013,6 +5022,30 @@ components: items: type: string description: Warnings when processing this node. + NvdImporter: + allOf: + - $ref: '#/components/schemas/CommonImporter' + - type: object + properties: + source: + type: string + description: |- + The base URL of the GitHub repository publishing NVD data as per-year + release assets (`CVE-.json.xz` + `.meta`), in the NVD-API JSON + schema. The latest release is always used. + startYear: + type: + - integer + - 'null' + format: int32 + minimum: 0 + years: + type: array + items: + type: integer + format: int32 + minimum: 0 + uniqueItems: true OrganizationDetails: allOf: - $ref: '#/components/schemas/OrganizationHead' diff --git a/server/src/sample_data.rs b/server/src/sample_data.rs index eae50500a..f1264092e 100644 --- a/server/src/sample_data.rs +++ b/server/src/sample_data.rs @@ -4,7 +4,7 @@ use trustify_common::db::{ReadWrite, pagination_cache::PaginationCache}; use trustify_module_importer::model::{ ClearlyDefinedImporter, ClearlyDefinedPackageType, CveImporter, CweImporter, DEFAULT_SOURCE_CLEARLY_DEFINED_CURATION, DEFAULT_SOURCE_CVEPROJECT, DEFAULT_SOURCE_CWE_CATALOG, - DEFAULT_SOURCE_QUAY, QuayImporter, + DEFAULT_SOURCE_NVD, DEFAULT_SOURCE_QUAY, NvdImporter, QuayImporter, }; use trustify_module_importer::{ model::{ @@ -82,6 +82,30 @@ async fn add_cve( .await } +async fn add_nvd( + importer: &ImporterService, + name: &str, + start_year: Option, + description: &str, +) -> anyhow::Result<()> { + add( + importer, + name, + ImporterConfiguration::Nvd(NvdImporter { + common: CommonImporter { + disabled: true, + period: Duration::from_secs(300), + description: Some(description.into()), + labels: Default::default(), + }, + source: DEFAULT_SOURCE_NVD.into(), + years: HashSet::default(), + start_year, + }), + ) + .await +} + async fn add_clearly_defined_curations( importer: &ImporterService, name: &str, @@ -253,6 +277,14 @@ pub async fn sample_data( ) .await?; + add_nvd( + &importer, + "nvd-from-2024", + Some(2024), + "NVD CVE feeds (starting 2024)", + ) + .await?; + add_clearly_defined_curations( &importer, "clearly-defined-curations", @@ -359,7 +391,7 @@ mod test { ImporterService::new(ReadWrite::new(ctx.db.clone()), PaginationCache::for_test()); let result = service.list().await?; - assert_eq!(result.len(), 16); + assert_eq!(result.len(), 17); Ok(()) } diff --git a/xtask/schema/generate-dump.json b/xtask/schema/generate-dump.json index ac84859fc..c0c639e0e 100644 --- a/xtask/schema/generate-dump.json +++ b/xtask/schema/generate-dump.json @@ -71,6 +71,18 @@ ], "additionalProperties": false }, + { + "type": "object", + "properties": { + "nvd": { + "$ref": "#/$defs/NvdImporter" + } + }, + "required": [ + "nvd" + ], + "additionalProperties": false + }, { "type": "object", "properties": { @@ -376,6 +388,58 @@ "period" ] }, + "NvdImporter": { + "type": "object", + "properties": { + "disabled": { + "description": "A flag to disable the importer, without deleting it.", + "type": "boolean", + "default": false + }, + "period": { + "description": "The period the importer should be run.", + "$ref": "#/$defs/HumantimeSerde" + }, + "description": { + "description": "A description for users.", + "type": [ + "string", + "null" + ] + }, + "labels": { + "description": "Labels which will be applied to the ingested documents.", + "$ref": "#/$defs/Labels" + }, + "source": { + "description": "The base URL of the GitHub repository publishing NVD data as per-year\nrelease assets (`CVE-.json.xz` + `.meta`), in the NVD-API JSON\nschema. The latest release is always used.", + "type": "string", + "default": "https://github.com/fkie-cad/nvd-json-data-feeds" + }, + "years": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + }, + "startYear": { + "type": [ + "integer", + "null" + ], + "format": "uint16", + "minimum": 0, + "maximum": 65535 + } + }, + "required": [ + "period" + ] + }, "ClearlyDefinedImporter": { "type": "object", "properties": {