use serde::{Deserialize, Serialize};
use crate::error::CoreError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Ecosystem {
#[serde(rename = "npm")]
Npm,
#[serde(rename = "PyPI")]
Pypi,
#[serde(rename = "Packagist")]
Packagist,
#[serde(rename = "Go")]
Go,
#[serde(rename = "Maven")]
Maven,
#[serde(rename = "crates.io")]
CratesIo,
}
impl Ecosystem {
pub fn all() -> [Self; 6] {
[
Self::Npm,
Self::Pypi,
Self::Packagist,
Self::Go,
Self::Maven,
Self::CratesIo,
]
}
pub fn as_osv_str(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Pypi => "PyPI",
Self::Packagist => "Packagist",
Self::Go => "Go",
Self::Maven => "Maven",
Self::CratesIo => "crates.io",
}
}
pub fn parse_config_name(name: &str) -> Result<Self, CoreError> {
match name.trim().to_ascii_lowercase().as_str() {
"npm" => Ok(Self::Npm),
"pypi" => Ok(Self::Pypi),
"packagist" | "composer" => Ok(Self::Packagist),
"go" => Ok(Self::Go),
"maven" => Ok(Self::Maven),
"cargo" | "crates.io" => Ok(Self::CratesIo),
other => Err(CoreError::UnknownEcosystem {
name: other.to_string(),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Package {
pub ecosystem: Ecosystem,
pub name: String,
pub version: String,
pub direct: Option<bool>,
#[serde(default)]
pub dev: bool,
pub source_file: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_files: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ParsedLockfile {
pub ecosystem: Ecosystem,
pub packages: Vec<Package>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Severity {
#[serde(rename = "type")]
pub kind: String,
pub score: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoImport {
pub path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Advisory {
pub id: String,
pub aliases: Vec<String>,
pub summary: String,
pub severity: Vec<Severity>,
pub database_specific_severity: Option<String>,
pub references: Vec<String>,
pub fixed_versions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<GoImport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Critical,
Review,
Defer,
}
impl Tier {
pub fn label(self) -> &'static str {
match self {
Self::Critical => "Critical",
Self::Review => "Review",
Self::Defer => "Defer",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
pub advisory: Advisory,
pub package: Package,
pub tier: Tier,
#[serde(default)]
pub score: u32,
#[serde(default)]
pub signals: FindingSignals,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub import_note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Report {
pub schema_version: u32,
pub findings: Vec<Finding>,
pub warnings: Vec<String>,
#[serde(default)]
pub summary: Summary,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ignored: Vec<IgnoredItem>,
#[serde(default)]
pub context: ReportContext,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct FindingSignals {
#[serde(default)]
pub cves: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cvss: Option<CvssSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epss: Option<EpssSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kev: Option<KevSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub breakdown: Option<BreakdownSignal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<Explanation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CvssSignal {
pub version: Option<String>,
pub vector: Option<String>,
pub base_score: f64,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EpssSignal {
pub score: f64,
pub percentile: f64,
pub date: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KevSignal {
pub date_added: String,
pub due_date: String,
pub ransomware: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BreakdownSignal {
pub kev: f64,
pub epss: f64,
pub cvss: f64,
pub direct: f64,
pub fix_available: f64,
pub total_weight: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Explanation {
pub agent: String,
pub text: String,
pub disclaimer: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockfileRecord {
pub path: String,
pub ecosystem: String,
pub packages: usize,
pub status: String,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IgnoredItem {
pub id: String,
pub package: String,
pub reason: String,
pub expires: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WeightSnapshot {
pub kev: f64,
pub epss: f64,
pub cvss: f64,
pub direct: f64,
pub fix_available: f64,
}
impl Default for WeightSnapshot {
fn default() -> Self {
Self {
kev: 35.0,
epss: 25.0,
cvss: 20.0,
direct: 10.0,
fix_available: 10.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReportContext {
pub target: String,
pub generated_at: String,
pub lockfiles: Vec<LockfileRecord>,
pub osv_fetched_at: String,
pub osv_from_cache: bool,
pub epss_date: Option<String>,
pub epss_from_cache: bool,
pub kev_catalog_version: Option<String>,
pub kev_from_cache: bool,
pub weights: WeightSnapshot,
pub threshold_critical: f64,
pub threshold_review: f64,
pub fail_on: String,
pub explain_status: String,
pub explain_agent: Option<String>,
}
impl Default for ReportContext {
fn default() -> Self {
Self {
target: ".".to_string(),
generated_at: String::new(),
lockfiles: Vec::new(),
osv_fetched_at: String::new(),
osv_from_cache: false,
epss_date: None,
epss_from_cache: false,
kev_catalog_version: None,
kev_from_cache: false,
weights: WeightSnapshot::default(),
threshold_critical: 70.0,
threshold_review: 40.0,
fail_on: "critical".to_string(),
explain_status: "not_requested".to_string(),
explain_agent: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Summary {
pub packages_scanned: usize,
pub findings: usize,
pub critical: usize,
pub review: usize,
pub defer: usize,
pub max_score: u32,
pub failed: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_names_map_to_osv_ecosystems() {
let cases = [
("npm", Ecosystem::Npm, "npm"),
("NPM", Ecosystem::Npm, "npm"),
("pypi", Ecosystem::Pypi, "PyPI"),
("PyPI", Ecosystem::Pypi, "PyPI"),
("packagist", Ecosystem::Packagist, "Packagist"),
("composer", Ecosystem::Packagist, "Packagist"),
("Composer", Ecosystem::Packagist, "Packagist"),
("go", Ecosystem::Go, "Go"),
("GO", Ecosystem::Go, "Go"),
("maven", Ecosystem::Maven, "Maven"),
("cargo", Ecosystem::CratesIo, "crates.io"),
("crates.io", Ecosystem::CratesIo, "crates.io"),
("Crates.IO", Ecosystem::CratesIo, "crates.io"),
];
for (input, ecosystem, osv) in cases {
let parsed = Ecosystem::parse_config_name(input).unwrap_or_else(|err| {
panic!("config name {input} should parse: {err}");
});
assert_eq!(parsed, ecosystem, "config name {input}");
assert_eq!(parsed.as_osv_str(), osv, "osv name for {input}");
}
}
#[test]
fn unknown_config_name_is_rejected() {
let err = Ecosystem::parse_config_name("nuget").unwrap_err();
let CoreError::UnknownEcosystem { name } = err else {
panic!("expected UnknownEcosystem");
};
assert_eq!(name, "nuget");
}
#[test]
fn tier_labels_are_english() {
assert_eq!(Tier::Critical.label(), "Critical");
assert_eq!(Tier::Review.label(), "Review");
assert_eq!(Tier::Defer.label(), "Defer");
}
#[test]
fn domain_types_roundtrip_through_json() {
for ecosystem in Ecosystem::all() {
let json = serde_json::to_string(&ecosystem).expect("serialize ecosystem");
assert_eq!(json, format!("\"{}\"", ecosystem.as_osv_str()));
let back: Ecosystem = serde_json::from_str(&json).expect("deserialize ecosystem");
assert_eq!(back, ecosystem);
}
let tier_json = serde_json::to_string(&Tier::Review).unwrap();
assert_eq!(tier_json, "\"review\"");
let tier: Tier = serde_json::from_str(&tier_json).unwrap();
assert_eq!(tier, Tier::Review);
let package = Package {
ecosystem: Ecosystem::Npm,
name: "lodash".to_string(),
version: "4.17.15".to_string(),
direct: Some(true),
dev: false,
source_file: "package-lock.json".to_string(),
source_files: Vec::new(),
line: None,
};
let advisory = Advisory {
id: "GHSA-35jh-r3h4-6jhm".to_string(),
aliases: vec!["CVE-2021-23337".to_string()],
summary: "Command Injection in lodash".to_string(),
severity: vec![Severity {
kind: "CVSS_V3".to_string(),
score: "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H".to_string(),
}],
database_specific_severity: Some("HIGH".to_string()),
references: vec!["https://nvd.nist.gov/vuln/detail/CVE-2021-23337".to_string()],
fixed_versions: vec!["4.17.21".to_string()],
imports: Vec::new(),
};
let parsed = ParsedLockfile {
ecosystem: Ecosystem::Npm,
packages: vec![package.clone()],
warnings: vec!["line 12 is not pinned, skipped".to_string()],
};
let finding = Finding {
advisory: advisory.clone(),
package: package.clone(),
tier: Tier::Defer,
score: 0,
signals: FindingSignals::default(),
import_note: None,
};
let report = Report {
schema_version: 1,
findings: vec![finding],
warnings: vec![],
summary: Summary::default(),
ignored: Vec::new(),
context: ReportContext::default(),
};
for value in [
serde_json::to_value(&package).unwrap(),
serde_json::to_value(&advisory).unwrap(),
serde_json::to_value(&parsed).unwrap(),
serde_json::to_value(&report).unwrap(),
] {
let text = serde_json::to_string(&value).unwrap();
let back: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(back, value);
}
let restored: Report =
serde_json::from_value(serde_json::to_value(&report).unwrap()).unwrap();
assert_eq!(restored.schema_version, 1);
assert_eq!(restored.findings[0].advisory.id, "GHSA-35jh-r3h4-6jhm");
assert_eq!(restored.findings[0].package.name, "lodash");
assert_eq!(restored.findings[0].tier.label(), "Defer");
assert!(restored.findings[0].advisory.imports.is_empty());
assert!(restored.findings[0].import_note.is_none());
}
#[test]
fn older_advisory_json_without_imports_still_parses() {
let advisory: Advisory = serde_json::from_str(
r#"{"id":"GO-1","aliases":[],"summary":"s","severity":[],"database_specific_severity":null,"references":[],"fixed_versions":[]}"#,
)
.expect("advisory");
assert!(advisory.imports.is_empty());
let finding: Finding = serde_json::from_str(
r#"{"advisory":{"id":"GO-1","aliases":[],"summary":"s","severity":[],"database_specific_severity":null,"references":[],"fixed_versions":[]},"package":{"ecosystem":"Go","name":"golang.org/x/crypto","version":"v0.1.0","direct":null,"dev":false,"source_file":"go.sum"},"tier":"defer"}"#,
)
.expect("finding");
assert!(finding.import_note.is_none());
assert!(finding.advisory.imports.is_empty());
assert_eq!(finding.score, 0);
}
}