intel/src/resolve.rs
//! Where a scan's advisories come from, and the evidence that says so.
//!
//! * **Offline** (`intel.offline = true`, the default on the secure side): the
//! local database, always. The network gate is [`IntelNet::offline`], which
//! has no fetcher behind it, so a fetch is refused, not attempted.
//! * **Online**: the feeds, fetched and normalized now. When any feed cannot be
//! fetched (no network), the local database, and the evidence says why.
//!
//! Either way the evidence names the bundle sequence and snapshot date, so a
//! stale database is visible in every verdict, and the [`StalenessRule`] turns
//! age into a warning and then into a block.
use std::sync::{Arc, Mutex, RwLock};
use anyhow::Result;
use chrono::{DateTime, Utc};
use modgunn::core::{Decision, Finding, Severity, Verdict};
use modgunn::scan::{AdvisoryDb, Artifact, OsvScanner, Policy, ScanEngine};
use serde::{Deserialize, Serialize};
use crate::db::{InstalledIntel, LocalIntelDb};
use crate::feeds::{normalize, FeedSpec, RawFeed};
use crate::net::IntelNet;
use crate::stale::{Staleness, StalenessRule};
use crate::supply::SupplyIntel;
/// A scan against a database older than `warn_after_days`.
pub const FINDING_STALE: &str = "HOLGER-INTEL-STALE";
/// A scan against a database older than `block_after_days`.
pub const FINDING_EXPIRED: &str = "HOLGER-INTEL-EXPIRED";
/// A scan with no advisory database at all.
pub const FINDING_MISSING: &str = "HOLGER-INTEL-MISSING";
/// The intel block of a Holger config, as the scanners need it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelSettings {
pub db_dir: String,
pub offline: bool,
pub rule: StalenessRule,
}
/// What a verdict was computed against.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelEvidence {
/// `local-intel`, `network`, or `none`.
pub source: String,
pub bundle_id: Option<String>,
pub sequence: Option<u64>,
/// `YYYY-MM-DD`: the snapshot date of the advisories.
pub snapshot_date: Option<String>,
pub age_days: Option<i64>,
pub staleness: Staleness,
pub advisories: u64,
/// Why the network was not used, when it was not.
pub network: Option<String>,
}
impl IntelEvidence {
fn local(
meta: &InstalledIntel,
rule: StalenessRule,
now: DateTime<Utc>,
network: Option<String>,
) -> Self {
Self {
source: "local-intel".into(),
bundle_id: Some(meta.bundle_id.clone()),
sequence: Some(meta.sequence),
snapshot_date: Some(meta.snapshot_at.format("%Y-%m-%d").to_string()),
age_days: Some(StalenessRule::age_days(meta.snapshot_at, now)),
staleness: rule.judge(meta.snapshot_at, now),
advisories: meta.advisories,
network,
}
}
fn missing(network: Option<String>) -> Self {
Self {
source: "none".into(),
bundle_id: None,
sequence: None,
snapshot_date: None,
age_days: None,
staleness: Staleness::Block,
advisories: 0,
network,
}
}
/// The revision the verdict cache keys on: bundle sequence and date, never
/// the age, so a cached verdict moves when the database does.
pub fn rev(&self) -> String {
match (&self.sequence, &self.snapshot_date) {
(Some(s), Some(d)) => format!("holger-intel seq {s} dated {d}"),
_ if self.source == "network" => format!(
"holger-intel network {}",
self.snapshot_date.as_deref().unwrap_or("-")
),
_ => "holger-intel none".into(),
}
}
/// One line for a report: `holger-intel seq 42 dated 2026-10-19 (3 d old, fresh; offline)`.
pub fn describe(&self) -> String {
let age = self
.age_days
.map(|d| format!("{d} d old, "))
.unwrap_or_default();
let net = self
.network
.as_deref()
.map(|n| format!("; {n}"))
.unwrap_or_default();
format!("{} ({age}{}{net})", self.rev(), self.staleness.as_str())
}
/// The finding a stale or missing database adds to every verdict.
pub fn finding(&self) -> Option<Finding> {
let what = self.rev();
match (self.staleness, self.source.as_str()) {
(Staleness::Fresh, _) => None,
(Staleness::Block, "none") => Some(Finding {
severity: Severity::High,
id: FINDING_MISSING.into(),
summary: "no advisory database is installed: import a signed intel bundle".into(),
}),
(Staleness::Warn, _) => Some(Finding {
severity: Severity::Medium,
id: FINDING_STALE.into(),
summary: format!("advisory database {what} is {} days old", self.age_days.unwrap_or(0)),
}),
(Staleness::Block, _) => Some(Finding {
severity: Severity::High,
id: FINDING_EXPIRED.into(),
summary: format!(
"advisory database {what} is {} days old, past the block limit: import a newer intel bundle",
self.age_days.unwrap_or(0)
),
}),
}
}
}
/// A database and its evidence.
#[derive(Debug, Clone)]
pub struct ResolvedAdvisories {
pub db: AdvisoryDb,
pub evidence: IntelEvidence,
/// The set's supply-chain section (Holger 3.2 phase B); empty when none.
pub supply: SupplyIntel,
}
/// The local database, judged by `rule` at `now`. No database is not an error:
/// it is a `none` evidence that blocks every scan.
pub fn resolve_local(
db: &LocalIntelDb,
rule: StalenessRule,
now: DateTime<Utc>,
network: Option<String>,
) -> Result<ResolvedAdvisories> {
match db.load()? {
None => Ok(ResolvedAdvisories {
db: AdvisoryDb::new("holger-intel none", vec![]),
evidence: IntelEvidence::missing(network),
supply: SupplyIntel::default(),
}),
Some((meta, set)) => {
let evidence = IntelEvidence::local(&meta, rule, now, network);
Ok(ResolvedAdvisories {
db: set.to_advisory_db(evidence.rev()),
evidence,
supply: set.supply,
})
}
}
}
/// The advisories for a scan: offline → local; online → the feeds, or local
/// when the network is not there.
pub fn resolve(
db: &LocalIntelDb,
rule: StalenessRule,
net: &IntelNet,
feeds: &[FeedSpec],
now: DateTime<Utc>,
) -> Result<ResolvedAdvisories> {
if net.is_offline() {
return resolve_local(db, rule, now, Some("offline: network refused".into()));
}
let mut raw = Vec::with_capacity(feeds.len());
for spec in feeds {
match net.get_feed(spec) {
Ok(bytes) => raw.push(RawFeed {
spec: spec.clone(),
bytes,
fetched_at: now,
}),
Err(e) => return resolve_local(db, rule, now, Some(format!("no network: {e}"))),
}
}
let set = normalize(&raw, now)?;
let evidence = IntelEvidence {
source: "network".into(),
bundle_id: None,
sequence: None,
snapshot_date: Some(now.format("%Y-%m-%d").to_string()),
age_days: Some(0),
staleness: Staleness::Fresh,
advisories: set.advisories.len() as u64,
network: None,
};
Ok(ResolvedAdvisories {
db: set.to_advisory_db(evidence.rev()),
evidence,
supply: set.supply,
})
}
/// A verdict and what it was computed against.
#[derive(Debug, Clone)]
pub struct IntelVerdict {
pub verdict: Verdict,
pub evidence: IntelEvidence,
}
/// modgunn's scanner over a resolved database, with the staleness rule folded
/// into every verdict.
pub struct IntelScanner {
inner: OsvScanner,
evidence: IntelEvidence,
}
impl IntelScanner {
pub fn new(policy: Policy, resolved: ResolvedAdvisories) -> Self {
Self {
inner: OsvScanner::new(policy, resolved.db),
evidence: resolved.evidence,
}
}
pub fn evidence(&self) -> &IntelEvidence {
&self.evidence
}
pub fn scanner(&self) -> &OsvScanner {
&self.inner
}
pub fn scan(&self, artifact: &Artifact) -> IntelVerdict {
IntelVerdict {
verdict: fold_staleness(self.inner.scan(artifact), &self.evidence),
evidence: self.evidence.clone(),
}
}
}
/// Scan `artifact` under `policy` against a shared database, staleness folded
/// in. The database is borrowed, never cloned, so a server holding one
/// [`LiveIntel`] set scans every request against the same allocation.
pub fn scan_against(
policy: &Policy,
db: &AdvisoryDb,
evidence: &IntelEvidence,
artifact: &Artifact,
) -> Verdict {
let matched = db.query(&artifact.artifact);
fold_staleness(policy.assemble(&db.rev, artifact, &matched), evidence)
}
/// Add the staleness finding to `v` and raise its decision: a warning to at
/// least Warn, an expired or missing database to Block.
pub fn fold_staleness(mut v: Verdict, evidence: &IntelEvidence) -> Verdict {
if let Some(f) = evidence.finding() {
v.findings.push(f);
let floor = match evidence.staleness {
Staleness::Fresh => Decision::Pass,
Staleness::Warn => Decision::Warn,
Staleness::Block => Decision::Block,
};
v.decision = v.decision.max(floor);
}
v
}
/// The server's live view of the local database: re-reads the small install
/// record per call and reloads the set only when the sequence moved, so an
/// import goes live without a restart.
/// One loaded generation: its key, its advisories and its supply-chain section.
type Loaded = (String, Arc<AdvisoryDb>, Arc<SupplyIntel>);
pub struct LiveIntel {
db: LocalIntelDb,
rule: StalenessRule,
cache: RwLock<Option<Loaded>>,
/// One load at a time (HOL-DOGFOOD-8): the scans that miss the cache together wait for
/// the one that loads, then read its copy, instead of each loading the whole set.
loading: Mutex<()>,
}
impl LiveIntel {
pub fn new(db: LocalIntelDb, rule: StalenessRule) -> Self {
Self {
db,
rule,
cache: RwLock::new(None),
loading: Mutex::new(()),
}
}
pub fn rule(&self) -> StalenessRule {
self.rule
}
/// The current database and its evidence at `now`.
pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, IntelEvidence)> {
self.current_all(now).map(|(db, _, e)| (db, e))
}
/// The current set's supply-chain section and its evidence at `now` (Holger 3.2
/// phase B): what the name and registry checks judge.
pub fn current_supply(&self, now: DateTime<Utc>) -> Result<(Arc<SupplyIntel>, IntelEvidence)> {
self.current_all(now).map(|(_, s, e)| (s, e))
}
fn current_all(
&self,
now: DateTime<Utc>,
) -> Result<(Arc<AdvisoryDb>, Arc<SupplyIntel>, IntelEvidence)> {
let Some(meta) = self.db.installed()? else {
let e = IntelEvidence::missing(Some("offline: network refused".into()));
return Ok((
Arc::new(AdvisoryDb::new(e.rev(), vec![])),
Arc::new(SupplyIntel::default()),
e,
));
};
let evidence = IntelEvidence::local(
&meta,
self.rule,
now,
Some("offline: network refused".into()),
);
let key = format!("{}#{}", meta.sequence, meta.advisories_sha256);
if let Some((db, supply)) = self.cached(&key)? {
return Ok((db, supply, evidence));
}
let _one_load = self
.loading
.lock()
.map_err(|_| anyhow::anyhow!("intel load lock poisoned"))?;
if let Some((db, supply)) = self.cached(&key)? {
return Ok((db, supply, evidence));
}
let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
let db = Arc::new(resolved.db);
let supply = Arc::new(resolved.supply);
*self
.cache
.write()
.map_err(|_| anyhow::anyhow!("intel cache poisoned"))? =
Some((key, db.clone(), supply.clone()));
Ok((db, supply, resolved.evidence))
}
fn cached(&self, key: &str) -> Result<Option<(Arc<AdvisoryDb>, Arc<SupplyIntel>)>> {
let cache = self
.cache
.read()
.map_err(|_| anyhow::anyhow!("intel cache poisoned"))?;
Ok(cache
.as_ref()
.filter(|(k, _, _)| k == key)
.map(|(_, db, supply)| (db.clone(), supply.clone())))
}
}