Skip to main content

holger_intel/
set.rs

1//! The normalized advisory set: one compact, deterministic JSON document that
2//! every feed folds into, and that both sides of the airgap agree on.
3
4use chrono::{DateTime, Utc};
5use modgunn::core::Severity;
6use modgunn::scan::{Advisory, AdvisoryDb};
7use modgunn::versions::RangeEvent;
8use serde::{Deserialize, Serialize};
9
10/// The schema the set document carries.
11pub const SET_SCHEMA: &str = "holger.intel-advisories/v1";
12
13/// One advisory, in the compact form the bundle carries. Empty fields are left
14/// out of the JSON, so a plain OSV range advisory costs a few hundred bytes.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct IntelAdvisory {
17    pub id: String,
18    pub ecosystem: String,
19    pub package: String,
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub versions: Vec<String>,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub ranges: Vec<Vec<RangeEvent>>,
24    pub severity: Severity,
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub summary: String,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub aliases: Vec<String>,
29    /// In CISA's Known Exploited Vulnerabilities catalog.
30    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
31    pub kev: bool,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub epss: Option<f64>,
34    #[serde(default, skip_serializing_if = "String::is_empty")]
35    pub cvss: String,
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub cwe: Vec<String>,
38    #[serde(default, skip_serializing_if = "String::is_empty")]
39    pub source: String,
40}
41
42impl IntelAdvisory {
43    pub fn from_advisory(a: &Advisory) -> Self {
44        Self {
45            id: a.id.clone(),
46            ecosystem: a.ecosystem.clone(),
47            package: a.package.clone(),
48            versions: a.affected_versions.clone(),
49            ranges: a.ranges.clone(),
50            severity: a.severity,
51            summary: a.summary.clone(),
52            aliases: a.aliases.clone(),
53            kev: a.kev,
54            epss: a.epss,
55            cvss: a.cvss_vector.clone(),
56            cwe: a.cwe.clone(),
57            source: a.source.clone(),
58        }
59    }
60
61    pub fn to_advisory(&self) -> Advisory {
62        let mut a = Advisory::basic(
63            self.id.clone(),
64            self.ecosystem.clone(),
65            self.package.clone(),
66            self.versions.clone(),
67            self.severity,
68            self.summary.clone(),
69        )
70        .with_source(self.source.clone())
71        .with_range_sets(self.ranges.clone());
72        a.aliases = self.aliases.clone();
73        a.kev = self.kev;
74        a.epss = self.epss;
75        a.cvss_vector = self.cvss.clone();
76        a.cwe = self.cwe.clone();
77        a
78    }
79}
80
81/// Where one feed's bytes came from, and what they hashed to.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct SourceRecord {
84    /// `osv:PyPI`, `nvd:recent`, `kev`.
85    pub feed: String,
86    pub uri: String,
87    pub sha256: String,
88    pub bytes: u64,
89    pub fetched_at: DateTime<Utc>,
90    /// Records the feed contributed (advisories, NVD entries or KEV ids).
91    pub records: u64,
92}
93
94/// The whole normalized set.
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96pub struct AdvisorySet {
97    pub schema: String,
98    pub generated_at: DateTime<Utc>,
99    pub sources: Vec<SourceRecord>,
100    pub advisories: Vec<IntelAdvisory>,
101}
102
103impl AdvisorySet {
104    /// A set from modgunn advisories: withdrawn ones are dropped, and the rest
105    /// are sorted so the same feeds always give the same bytes.
106    pub fn from_advisories(
107        generated_at: DateTime<Utc>,
108        sources: Vec<SourceRecord>,
109        advisories: &[Advisory],
110    ) -> Self {
111        let mut out: Vec<IntelAdvisory> = advisories
112            .iter()
113            .filter(|a| !a.withdrawn)
114            .map(IntelAdvisory::from_advisory)
115            .collect();
116        out.sort_by(|a, b| {
117            (&a.ecosystem, &a.package, &a.id).cmp(&(&b.ecosystem, &b.package, &b.id))
118        });
119        Self { schema: SET_SCHEMA.to_string(), generated_at, sources, advisories: out }
120    }
121
122    pub fn to_json_bytes(&self) -> serde_json::Result<Vec<u8>> {
123        serde_json::to_vec(self)
124    }
125
126    /// Parse and check the schema.
127    pub fn from_json_bytes(bytes: &[u8]) -> anyhow::Result<Self> {
128        let set: Self = serde_json::from_slice(bytes)
129            .map_err(|e| anyhow::anyhow!("the advisory set is not valid JSON: {e}"))?;
130        anyhow::ensure!(
131            set.schema == SET_SCHEMA,
132            "the advisory set has schema {:?}; this build reads {SET_SCHEMA:?}",
133            set.schema
134        );
135        Ok(set)
136    }
137
138    /// The set as the scanner's database, under revision `rev`.
139    pub fn to_advisory_db(&self, rev: impl Into<String>) -> AdvisoryDb {
140        AdvisoryDb::new(rev, self.advisories.iter().map(IntelAdvisory::to_advisory).collect())
141    }
142}