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(
59        meta: &InstalledIntel,
60        rule: StalenessRule,
61        now: DateTime<Utc>,
62        network: Option<String>,
63    ) -> Self {
64        Self {
65            source: "local-intel".into(),
66            bundle_id: Some(meta.bundle_id.clone()),
67            sequence: Some(meta.sequence),
68            snapshot_date: Some(meta.snapshot_at.format("%Y-%m-%d").to_string()),
69            age_days: Some(StalenessRule::age_days(meta.snapshot_at, now)),
70            staleness: rule.judge(meta.snapshot_at, now),
71            advisories: meta.advisories,
72            network,
73        }
74    }
75
76    fn missing(network: Option<String>) -> Self {
77        Self {
78            source: "none".into(),
79            bundle_id: None,
80            sequence: None,
81            snapshot_date: None,
82            age_days: None,
83            staleness: Staleness::Block,
84            advisories: 0,
85            network,
86        }
87    }
88
89    /// The revision the verdict cache keys on: bundle sequence and date, never
90    /// the age, so a cached verdict moves when the database does.
91    pub fn rev(&self) -> String {
92        match (&self.sequence, &self.snapshot_date) {
93            (Some(s), Some(d)) => format!("holger-intel seq {s} dated {d}"),
94            _ if self.source == "network" => format!(
95                "holger-intel network {}",
96                self.snapshot_date.as_deref().unwrap_or("-")
97            ),
98            _ => "holger-intel none".into(),
99        }
100    }
101
102    /// One line for a report: `holger-intel seq 42 dated 2026-10-19 (3 d old, fresh; offline)`.
103    pub fn describe(&self) -> String {
104        let age = self
105            .age_days
106            .map(|d| format!("{d} d old, "))
107            .unwrap_or_default();
108        let net = self
109            .network
110            .as_deref()
111            .map(|n| format!("; {n}"))
112            .unwrap_or_default();
113        format!("{} ({age}{}{net})", self.rev(), self.staleness.as_str())
114    }
115
116    /// The finding a stale or missing database adds to every verdict.
117    pub fn finding(&self) -> Option<Finding> {
118        let what = self.rev();
119        match (self.staleness, self.source.as_str()) {
120            (Staleness::Fresh, _) => None,
121            (Staleness::Block, "none") => Some(Finding {
122                severity: Severity::High,
123                id: FINDING_MISSING.into(),
124                summary: "no advisory database is installed: import a signed intel bundle".into(),
125            }),
126            (Staleness::Warn, _) => Some(Finding {
127                severity: Severity::Medium,
128                id: FINDING_STALE.into(),
129                summary: format!("advisory database {what} is {} days old", self.age_days.unwrap_or(0)),
130            }),
131            (Staleness::Block, _) => Some(Finding {
132                severity: Severity::High,
133                id: FINDING_EXPIRED.into(),
134                summary: format!(
135                    "advisory database {what} is {} days old, past the block limit: import a newer intel bundle",
136                    self.age_days.unwrap_or(0)
137                ),
138            }),
139        }
140    }
141}
142
143/// A database and its evidence.
144#[derive(Debug, Clone)]
145pub struct ResolvedAdvisories {
146    pub db: AdvisoryDb,
147    pub evidence: IntelEvidence,
148}
149
150/// The local database, judged by `rule` at `now`. No database is not an error:
151/// it is a `none` evidence that blocks every scan.
152pub fn resolve_local(
153    db: &LocalIntelDb,
154    rule: StalenessRule,
155    now: DateTime<Utc>,
156    network: Option<String>,
157) -> Result<ResolvedAdvisories> {
158    match db.load()? {
159        None => Ok(ResolvedAdvisories {
160            db: AdvisoryDb::new("holger-intel none", vec![]),
161            evidence: IntelEvidence::missing(network),
162        }),
163        Some((meta, set)) => {
164            let evidence = IntelEvidence::local(&meta, rule, now, network);
165            Ok(ResolvedAdvisories {
166                db: set.to_advisory_db(evidence.rev()),
167                evidence,
168            })
169        }
170    }
171}
172
173/// The advisories for a scan: offline → local; online → the feeds, or local
174/// when the network is not there.
175pub fn resolve(
176    db: &LocalIntelDb,
177    rule: StalenessRule,
178    net: &IntelNet,
179    feeds: &[FeedSpec],
180    now: DateTime<Utc>,
181) -> Result<ResolvedAdvisories> {
182    if net.is_offline() {
183        return resolve_local(db, rule, now, Some("offline: network refused".into()));
184    }
185    let mut raw = Vec::with_capacity(feeds.len());
186    for spec in feeds {
187        match net.get(&spec.uri) {
188            Ok(bytes) => raw.push(RawFeed {
189                spec: spec.clone(),
190                bytes,
191                fetched_at: now,
192            }),
193            Err(e) => return resolve_local(db, rule, now, Some(format!("no network: {e}"))),
194        }
195    }
196    let set = normalize(&raw, now)?;
197    let evidence = IntelEvidence {
198        source: "network".into(),
199        bundle_id: None,
200        sequence: None,
201        snapshot_date: Some(now.format("%Y-%m-%d").to_string()),
202        age_days: Some(0),
203        staleness: Staleness::Fresh,
204        advisories: set.advisories.len() as u64,
205        network: None,
206    };
207    Ok(ResolvedAdvisories {
208        db: set.to_advisory_db(evidence.rev()),
209        evidence,
210    })
211}
212
213/// A verdict and what it was computed against.
214#[derive(Debug, Clone)]
215pub struct IntelVerdict {
216    pub verdict: Verdict,
217    pub evidence: IntelEvidence,
218}
219
220/// modgunn's scanner over a resolved database, with the staleness rule folded
221/// into every verdict.
222pub struct IntelScanner {
223    inner: OsvScanner,
224    evidence: IntelEvidence,
225}
226
227impl IntelScanner {
228    pub fn new(policy: Policy, resolved: ResolvedAdvisories) -> Self {
229        Self {
230            inner: OsvScanner::new(policy, resolved.db),
231            evidence: resolved.evidence,
232        }
233    }
234
235    pub fn evidence(&self) -> &IntelEvidence {
236        &self.evidence
237    }
238
239    pub fn scanner(&self) -> &OsvScanner {
240        &self.inner
241    }
242
243    pub fn scan(&self, artifact: &Artifact) -> IntelVerdict {
244        IntelVerdict {
245            verdict: fold_staleness(self.inner.scan(artifact), &self.evidence),
246            evidence: self.evidence.clone(),
247        }
248    }
249}
250
251/// Scan `artifact` under `policy` against a shared database, staleness folded
252/// in. The database is borrowed, never cloned, so a server holding one
253/// [`LiveIntel`] set scans every request against the same allocation.
254pub fn scan_against(
255    policy: &Policy,
256    db: &AdvisoryDb,
257    evidence: &IntelEvidence,
258    artifact: &Artifact,
259) -> Verdict {
260    let matched = db.query(&artifact.artifact);
261    fold_staleness(policy.assemble(&db.rev, artifact, &matched), evidence)
262}
263
264/// Add the staleness finding to `v` and raise its decision: a warning to at
265/// least Warn, an expired or missing database to Block.
266pub fn fold_staleness(mut v: Verdict, evidence: &IntelEvidence) -> Verdict {
267    if let Some(f) = evidence.finding() {
268        v.findings.push(f);
269        let floor = match evidence.staleness {
270            Staleness::Fresh => Decision::Pass,
271            Staleness::Warn => Decision::Warn,
272            Staleness::Block => Decision::Block,
273        };
274        v.decision = v.decision.max(floor);
275    }
276    v
277}
278
279/// The server's live view of the local database: re-reads the small install
280/// record per call and reloads the set only when the sequence moved, so an
281/// import goes live without a restart.
282pub struct LiveIntel {
283    db: LocalIntelDb,
284    rule: StalenessRule,
285    cache: RwLock<Option<(String, Arc<AdvisoryDb>)>>,
286}
287
288impl LiveIntel {
289    pub fn new(db: LocalIntelDb, rule: StalenessRule) -> Self {
290        Self {
291            db,
292            rule,
293            cache: RwLock::new(None),
294        }
295    }
296
297    pub fn rule(&self) -> StalenessRule {
298        self.rule
299    }
300
301    /// The current database and its evidence at `now`.
302    pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, IntelEvidence)> {
303        let Some(meta) = self.db.installed()? else {
304            let e = IntelEvidence::missing(Some("offline: network refused".into()));
305            return Ok((Arc::new(AdvisoryDb::new(e.rev(), vec![])), e));
306        };
307        let evidence = IntelEvidence::local(
308            &meta,
309            self.rule,
310            now,
311            Some("offline: network refused".into()),
312        );
313        let key = format!("{}#{}", meta.sequence, meta.advisories_sha256);
314        if let Some((k, db)) = self
315            .cache
316            .read()
317            .map_err(|_| anyhow::anyhow!("intel cache poisoned"))?
318            .as_ref()
319        {
320            if *k == key {
321                return Ok((db.clone(), evidence));
322            }
323        }
324        let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
325        let db = Arc::new(resolved.db);
326        *self
327            .cache
328            .write()
329            .map_err(|_| anyhow::anyhow!("intel cache poisoned"))? = Some((key, db.clone()));
330        Ok((db, resolved.evidence))
331    }
332}