pub mod cache;
pub mod epss;
pub mod kev;
pub mod license;
pub mod maintainer;
pub mod osv;
pub mod registry;
pub mod typosquat;
pub mod version_jump;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use maintainer::MaintainerAgeFinding;
use registry::{Deprecated, MaintainerSetChanged, RecentlyPublished};
use typosquat::TyposquatFinding;
use version_jump::VersionJumpFinding;
use crate::vex::VexAnnotation;
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Enrichment {
pub vulns: HashMap<String, Vec<VulnRef>>,
pub typosquats: Vec<TyposquatFinding>,
pub version_jumps: Vec<VersionJumpFinding>,
pub maintainer_age: Vec<MaintainerAgeFinding>,
pub license_violations: Vec<LicenseViolation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub recently_published: Vec<RecentlyPublished>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deprecated: Vec<Deprecated>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub maintainer_set_changed: Vec<MaintainerSetChanged>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub vex_annotations: HashMap<String, VexAnnotation>,
#[serde(default, skip_serializing_if = "is_zero_usize")]
pub vex_suppressed_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub plugin_findings: Vec<crate::plugin::PluginFinding>,
}
fn is_zero_usize(n: &usize) -> bool {
*n == 0
}
impl Enrichment {
pub fn vulns_for(&self, purl: Option<&str>) -> &[VulnRef] {
match purl {
Some(p) => self.vulns.get(p).map(Vec::as_slice).unwrap_or(&[]),
None => &[],
}
}
pub fn has_findings(&self) -> bool {
!self.vulns.is_empty()
|| !self.typosquats.is_empty()
|| !self.version_jumps.is_empty()
|| !self.maintainer_age.is_empty()
|| !self.license_violations.is_empty()
|| !self.recently_published.is_empty()
|| !self.deprecated.is_empty()
|| !self.maintainer_set_changed.is_empty()
|| !self.plugin_findings.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LicenseViolation {
pub component: crate::model::Component,
pub license: String,
pub matched_rule: String,
pub kind: LicenseViolationKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum LicenseViolationKind {
Deny,
Ambiguous,
NotAllowed,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct VulnRef {
pub id: String,
pub severity: Severity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epss_score: Option<f32>,
#[serde(default, skip_serializing_if = "is_false")]
pub kev: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl VulnRef {
pub fn new(id: impl Into<String>, severity: Severity) -> Self {
Self {
id: id.into(),
severity,
aliases: Vec::new(),
epss_score: None,
kev: false,
}
}
pub fn cves(&self) -> impl Iterator<Item = &str> {
let primary = if self.id.starts_with("CVE-") {
Some(self.id.as_str())
} else {
None
};
primary.into_iter().chain(
self.aliases
.iter()
.map(String::as_str)
.filter(|a| a.starts_with("CVE-")),
)
}
}
impl Default for Severity {
fn default() -> Self {
Self::None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
None,
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn from_ghsa_label(label: &str) -> Self {
match label.trim().to_ascii_uppercase().as_str() {
"CRITICAL" => Self::Critical,
"HIGH" => Self::High,
"MEDIUM" | "MODERATE" => Self::Medium,
"LOW" => Self::Low,
_ => Self::None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Critical => "CRITICAL",
Self::High => "HIGH",
Self::Medium => "MEDIUM",
Self::Low => "LOW",
Self::None => "NONE",
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented
)]
use super::*;
#[test]
fn ghsa_label_parses_canonical_forms() {
assert_eq!(Severity::from_ghsa_label("CRITICAL"), Severity::Critical);
assert_eq!(Severity::from_ghsa_label("HIGH"), Severity::High);
assert_eq!(Severity::from_ghsa_label("MODERATE"), Severity::Medium);
assert_eq!(Severity::from_ghsa_label("MEDIUM"), Severity::Medium);
assert_eq!(Severity::from_ghsa_label("LOW"), Severity::Low);
}
#[test]
fn ghsa_label_is_case_insensitive_and_trim_tolerant() {
assert_eq!(Severity::from_ghsa_label(" Critical "), Severity::Critical);
assert_eq!(Severity::from_ghsa_label("moderate"), Severity::Medium);
}
#[test]
fn unknown_label_falls_back_to_none() {
assert_eq!(Severity::from_ghsa_label(""), Severity::None);
assert_eq!(Severity::from_ghsa_label("urgent"), Severity::None);
}
#[test]
fn severity_ordering_low_to_high() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::None);
}
#[test]
fn severity_serializes_as_uppercase_string() {
let s = serde_json::to_string(&Severity::High).unwrap();
assert_eq!(s, "\"HIGH\"");
}
}