intel/src/set.rs
//! The normalized advisory set: one compact, deterministic JSON document that
//! every feed folds into, and that both sides of the airgap agree on.
use chrono::{DateTime, Utc};
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
use modgunn::versions::RangeEvent;
use serde::{Deserialize, Serialize};
use crate::supply::SupplyIntel;
/// The schema the set document carries.
pub const SET_SCHEMA: &str = "holger.intel-advisories/v1";
/// One advisory, in the compact form the bundle carries. Empty fields are left
/// out of the JSON, so a plain OSV range advisory costs a few hundred bytes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelAdvisory {
pub id: String,
pub ecosystem: String,
pub package: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ranges: Vec<Vec<RangeEvent>>,
pub severity: Severity,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
/// In CISA's Known Exploited Vulnerabilities catalog.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub kev: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub epss: Option<f64>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub cvss: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cwe: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source: String,
/// **The vulnerable functions** the advisory names (Holger 3.2, H32-APP-HIT): RustSec's
/// `affected.functions` (`smallvec::SmallVec::insert_many`), the Go vuln DB's
/// `path.Symbol`. Empty: the advisory is not narrowed to a function.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub functions: Vec<String>,
/// **A malicious package** (an OSV `MAL-` record, a RustSec advisory of category
/// `malicious`): affected whatever is called.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub malicious: bool,
}
impl IntelAdvisory {
pub fn from_advisory(a: &Advisory) -> Self {
Self {
id: a.id.clone(),
ecosystem: a.ecosystem.clone(),
package: a.package.clone(),
versions: a.affected_versions.clone(),
ranges: a.ranges.clone(),
severity: a.severity,
summary: a.summary.clone(),
aliases: a.aliases.clone(),
kev: a.kev,
epss: a.epss,
cvss: a.cvss_vector.clone(),
cwe: a.cwe.clone(),
source: a.source.clone(),
functions: Vec::new(),
malicious: false,
}
}
pub fn to_advisory(&self) -> Advisory {
let mut a = Advisory::basic(
self.id.clone(),
self.ecosystem.clone(),
self.package.clone(),
self.versions.clone(),
self.severity,
self.summary.clone(),
)
.with_source(self.source.clone())
.with_range_sets(self.ranges.clone());
a.aliases = self.aliases.clone();
a.kev = self.kev;
a.epss = self.epss;
a.cvss_vector = self.cvss.clone();
a.cwe = self.cwe.clone();
a
}
}
/// Where one feed's bytes came from, and what they hashed to.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SourceRecord {
/// `osv:PyPI`, `nvd:recent`, `kev`.
pub feed: String,
pub uri: String,
pub sha256: String,
pub bytes: u64,
pub fetched_at: DateTime<Utc>,
/// Records the feed contributed (advisories, NVD entries or KEV ids).
pub records: u64,
}
/// The whole normalized set.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AdvisorySet {
pub schema: String,
pub generated_at: DateTime<Utc>,
pub sources: Vec<SourceRecord>,
pub advisories: Vec<IntelAdvisory>,
/// The supply-chain facts the name and registry checks judge (Holger 3.2 phase B):
/// popular names per ecosystem. Absent in a set that carries none.
#[serde(default, skip_serializing_if = "SupplyIntel::is_empty")]
pub supply: SupplyIntel,
}
impl AdvisorySet {
/// A set from modgunn advisories: withdrawn ones are dropped, and the rest
/// are sorted so the same feeds always give the same bytes.
pub fn from_advisories(
generated_at: DateTime<Utc>,
sources: Vec<SourceRecord>,
advisories: &[Advisory],
) -> Self {
let mut out: Vec<IntelAdvisory> = advisories
.iter()
.filter(|a| !a.withdrawn)
.map(IntelAdvisory::from_advisory)
.collect();
out.sort_by(|a, b| {
(&a.ecosystem, &a.package, &a.id).cmp(&(&b.ecosystem, &b.package, &b.id))
});
Self {
schema: SET_SCHEMA.to_string(),
generated_at,
sources,
advisories: out,
supply: SupplyIntel::default(),
}
}
pub fn to_json_bytes(&self) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec(self)
}
/// Parse and check the schema.
pub fn from_json_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
let set: Self = serde_json::from_slice(bytes)
.map_err(|e| anyhow::anyhow!("the advisory set is not valid JSON: {e}"))?;
anyhow::ensure!(
set.schema == SET_SCHEMA,
"the advisory set has schema {:?}; this build reads {SET_SCHEMA:?}",
set.schema
);
Ok(set)
}
/// The set as the scanner's database, under revision `rev`.
pub fn to_advisory_db(&self, rev: impl Into<String>) -> AdvisoryDb {
AdvisoryDb::new(
rev,
self.advisories
.iter()
.map(IntelAdvisory::to_advisory)
.collect(),
)
}
}