Skip to main content

holger_intel/
resolve.rs

1//! Where a scan's advisories come from, and the evidence that says so.
2//!
3//! * **Offline** (`intel.offline = true`, the default on the secure side): the
4//!   local database, always. The network gate is [`IntelNet::offline`], which
5//!   has no fetcher behind it, so a fetch is refused, not attempted.
6//! * **Online**: the feeds, fetched and normalized now. When any feed cannot be
7//!   fetched (no network), the local database, and the evidence says why.
8//!
9//! Either way the evidence names the bundle sequence and snapshot date, so a
10//! stale database is visible in every verdict, and the [`StalenessRule`] turns
11//! age into a warning and then into a block.
12
13use std::sync::{Arc, RwLock};
14
15use anyhow::Result;
16use chrono::{DateTime, Utc};
17use modgunn::core::{Decision, Finding, Severity, Verdict};
18use modgunn::scan::{AdvisoryDb, Artifact, OsvScanner, Policy, ScanEngine};
19use serde::{Deserialize, Serialize};
20
21use crate::db::{InstalledIntel, LocalIntelDb};
22use crate::feeds::{normalize, FeedSpec, RawFeed};
23use crate::net::IntelNet;
24use crate::stale::{Staleness, StalenessRule};
25
26/// A scan against a database older than `warn_after_days`.
27pub const FINDING_STALE: &str = "HOLGER-INTEL-STALE";
28/// A scan against a database older than `block_after_days`.
29pub const FINDING_EXPIRED: &str = "HOLGER-INTEL-EXPIRED";
30/// A scan with no advisory database at all.
31pub const FINDING_MISSING: &str = "HOLGER-INTEL-MISSING";
32
33/// The intel block of a Holger config, as the scanners need it.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct IntelSettings {
36    pub db_dir: String,
37    pub offline: bool,
38    pub rule: StalenessRule,
39}
40
41/// What a verdict was computed against.
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct IntelEvidence {
44    /// `local-intel`, `network`, or `none`.
45    pub source: String,
46    pub bundle_id: Option<String>,
47    pub sequence: Option<u64>,
48    /// `YYYY-MM-DD`: the snapshot date of the advisories.
49    pub snapshot_date: Option<String>,
50    pub age_days: Option<i64>,
51    pub staleness: Staleness,
52    pub advisories: u64,
53    /// Why the network was not used, when it was not.
54    pub network: Option<String>,
55}
56
57impl IntelEvidence {
58    fn local(meta: &InstalledIntel, rule: StalenessRule, now: DateTime<Utc>, network: Option<String>) -> Self {
59        Self {
60            source: "local-intel".into(),
61            bundle_id: Some(meta.bundle_id.clone()),
62            sequence: Some(meta.sequence),
63            snapshot_date: Some(meta.snapshot_at.format("%Y-%m-%d").to_string()),
64            age_days: Some(StalenessRule::age_days(meta.snapshot_at, now)),
65            staleness: rule.judge(meta.snapshot_at, now),
66            advisories: meta.advisories,
67            network,
68        }
69    }
70
71    fn missing(network: Option<String>) -> Self {
72        Self {
73            source: "none".into(),
74            bundle_id: None,
75            sequence: None,
76            snapshot_date: None,
77            age_days: None,
78            staleness: Staleness::Block,
79            advisories: 0,
80            network,
81        }
82    }
83
84    /// The revision the verdict cache keys on: bundle sequence and date, never
85    /// the age, so a cached verdict moves when the database does.
86    pub fn rev(&self) -> String {
87        match (&self.sequence, &self.snapshot_date) {
88            (Some(s), Some(d)) => format!("holger-intel seq {s} dated {d}"),
89            _ if self.source == "network" => format!("holger-intel network {}", self.snapshot_date.as_deref().unwrap_or("-")),
90            _ => "holger-intel none".into(),
91        }
92    }
93
94    /// One line for a report: `holger-intel seq 42 dated 2026-10-19 (3 d old, fresh; offline)`.
95    pub fn describe(&self) -> String {
96        let age = self.age_days.map(|d| format!("{d} d old, ")).unwrap_or_default();
97        let net = self.network.as_deref().map(|n| format!("; {n}")).unwrap_or_default();
98        format!("{} ({age}{}{net})", self.rev(), self.staleness.as_str())
99    }
100
101    /// The finding a stale or missing database adds to every verdict.
102    pub fn finding(&self) -> Option<Finding> {
103        let what = self.rev();
104        match (self.staleness, self.source.as_str()) {
105            (Staleness::Fresh, _) => None,
106            (Staleness::Block, "none") => Some(Finding {
107                severity: Severity::High,
108                id: FINDING_MISSING.into(),
109                summary: "no advisory database is installed: import a signed intel bundle".into(),
110            }),
111            (Staleness::Warn, _) => Some(Finding {
112                severity: Severity::Medium,
113                id: FINDING_STALE.into(),
114                summary: format!("advisory database {what} is {} days old", self.age_days.unwrap_or(0)),
115            }),
116            (Staleness::Block, _) => Some(Finding {
117                severity: Severity::High,
118                id: FINDING_EXPIRED.into(),
119                summary: format!(
120                    "advisory database {what} is {} days old, past the block limit: import a newer intel bundle",
121                    self.age_days.unwrap_or(0)
122                ),
123            }),
124        }
125    }
126}
127
128/// A database and its evidence.
129#[derive(Debug, Clone)]
130pub struct ResolvedAdvisories {
131    pub db: AdvisoryDb,
132    pub evidence: IntelEvidence,
133}
134
135/// The local database, judged by `rule` at `now`. No database is not an error:
136/// it is a `none` evidence that blocks every scan.
137pub fn resolve_local(db: &LocalIntelDb, rule: StalenessRule, now: DateTime<Utc>, network: Option<String>) -> Result<ResolvedAdvisories> {
138    match db.load()? {
139        None => Ok(ResolvedAdvisories { db: AdvisoryDb::new("holger-intel none", vec![]), evidence: IntelEvidence::missing(network) }),
140        Some((meta, set)) => {
141            let evidence = IntelEvidence::local(&meta, rule, now, network);
142            Ok(ResolvedAdvisories { db: set.to_advisory_db(evidence.rev()), evidence })
143        }
144    }
145}
146
147/// The advisories for a scan: offline → local; online → the feeds, or local
148/// when the network is not there.
149pub fn resolve(
150    db: &LocalIntelDb,
151    rule: StalenessRule,
152    net: &IntelNet,
153    feeds: &[FeedSpec],
154    now: DateTime<Utc>,
155) -> Result<ResolvedAdvisories> {
156    if net.is_offline() {
157        return resolve_local(db, rule, now, Some("offline: network refused".into()));
158    }
159    let mut raw = Vec::with_capacity(feeds.len());
160    for spec in feeds {
161        match net.get(&spec.uri) {
162            Ok(bytes) => raw.push(RawFeed { spec: spec.clone(), bytes, fetched_at: now }),
163            Err(e) => return resolve_local(db, rule, now, Some(format!("no network: {e}"))),
164        }
165    }
166    let set = normalize(&raw, now)?;
167    let evidence = IntelEvidence {
168        source: "network".into(),
169        bundle_id: None,
170        sequence: None,
171        snapshot_date: Some(now.format("%Y-%m-%d").to_string()),
172        age_days: Some(0),
173        staleness: Staleness::Fresh,
174        advisories: set.advisories.len() as u64,
175        network: None,
176    };
177    Ok(ResolvedAdvisories { db: set.to_advisory_db(evidence.rev()), evidence })
178}
179
180/// A verdict and what it was computed against.
181#[derive(Debug, Clone)]
182pub struct IntelVerdict {
183    pub verdict: Verdict,
184    pub evidence: IntelEvidence,
185}
186
187/// modgunn's scanner over a resolved database, with the staleness rule folded
188/// into every verdict.
189pub struct IntelScanner {
190    inner: OsvScanner,
191    evidence: IntelEvidence,
192}
193
194impl IntelScanner {
195    pub fn new(policy: Policy, resolved: ResolvedAdvisories) -> Self {
196        Self { inner: OsvScanner::new(policy, resolved.db), evidence: resolved.evidence }
197    }
198
199    pub fn evidence(&self) -> &IntelEvidence {
200        &self.evidence
201    }
202
203    pub fn scanner(&self) -> &OsvScanner {
204        &self.inner
205    }
206
207    pub fn scan(&self, artifact: &Artifact) -> IntelVerdict {
208        IntelVerdict { verdict: fold_staleness(self.inner.scan(artifact), &self.evidence), evidence: self.evidence.clone() }
209    }
210}
211
212/// Scan `artifact` under `policy` against a shared database, staleness folded
213/// in. The database is borrowed, never cloned, so a server holding one
214/// [`LiveIntel`] set scans every request against the same allocation.
215pub fn scan_against(policy: &Policy, db: &AdvisoryDb, evidence: &IntelEvidence, artifact: &Artifact) -> Verdict {
216    let matched = db.query(&artifact.artifact);
217    fold_staleness(policy.assemble(&db.rev, artifact, &matched), evidence)
218}
219
220/// Add the staleness finding to `v` and raise its decision: a warning to at
221/// least Warn, an expired or missing database to Block.
222pub fn fold_staleness(mut v: Verdict, evidence: &IntelEvidence) -> Verdict {
223    if let Some(f) = evidence.finding() {
224        v.findings.push(f);
225        let floor = match evidence.staleness {
226            Staleness::Fresh => Decision::Pass,
227            Staleness::Warn => Decision::Warn,
228            Staleness::Block => Decision::Block,
229        };
230        v.decision = v.decision.max(floor);
231    }
232    v
233}
234
235/// The server's live view of the local database: re-reads the small install
236/// record per call and reloads the set only when the sequence moved, so an
237/// import goes live without a restart.
238pub struct LiveIntel {
239    db: LocalIntelDb,
240    rule: StalenessRule,
241    cache: RwLock<Option<(String, Arc<AdvisoryDb>)>>,
242}
243
244impl LiveIntel {
245    pub fn new(db: LocalIntelDb, rule: StalenessRule) -> Self {
246        Self { db, rule, cache: RwLock::new(None) }
247    }
248
249    pub fn rule(&self) -> StalenessRule {
250        self.rule
251    }
252
253    /// The current database and its evidence at `now`.
254    pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, IntelEvidence)> {
255        let Some(meta) = self.db.installed()? else {
256            let e = IntelEvidence::missing(Some("offline: network refused".into()));
257            return Ok((Arc::new(AdvisoryDb::new(e.rev(), vec![])), e));
258        };
259        let evidence = IntelEvidence::local(&meta, self.rule, now, Some("offline: network refused".into()));
260        let key = format!("{}#{}", meta.sequence, meta.advisories_sha256);
261        if let Some((k, db)) = self.cache.read().map_err(|_| anyhow::anyhow!("intel cache poisoned"))?.as_ref() {
262            if *k == key {
263                return Ok((db.clone(), evidence));
264            }
265        }
266        let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
267        let db = Arc::new(resolved.db);
268        *self.cache.write().map_err(|_| anyhow::anyhow!("intel cache poisoned"))? = Some((key, db.clone()));
269        Ok((db, resolved.evidence))
270    }
271}