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};
pub const FINDING_STALE: &str = "HOLGER-INTEL-STALE";
pub const FINDING_EXPIRED: &str = "HOLGER-INTEL-EXPIRED";
pub const FINDING_MISSING: &str = "HOLGER-INTEL-MISSING";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelSettings {
pub db_dir: String,
pub offline: bool,
pub rule: StalenessRule,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntelEvidence {
pub source: String,
pub bundle_id: Option<String>,
pub sequence: Option<u64>,
pub snapshot_date: Option<String>,
pub age_days: Option<i64>,
pub staleness: Staleness,
pub advisories: u64,
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,
}
}
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(),
}
}
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())
}
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)
),
}),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedAdvisories {
pub db: AdvisoryDb,
pub evidence: IntelEvidence,
}
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),
}),
Some((meta, set)) => {
let evidence = IntelEvidence::local(&meta, rule, now, network);
Ok(ResolvedAdvisories {
db: set.to_advisory_db(evidence.rev()),
evidence,
})
}
}
}
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,
})
}
#[derive(Debug, Clone)]
pub struct IntelVerdict {
pub verdict: Verdict,
pub evidence: IntelEvidence,
}
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(),
}
}
}
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)
}
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
}
pub struct LiveIntel {
db: LocalIntelDb,
rule: StalenessRule,
cache: RwLock<Option<(String, Arc<AdvisoryDb>)>>,
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
}
pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, 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![])), 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) = self.cached(&key)? {
return Ok((db, evidence));
}
let _one_load = self
.loading
.lock()
.map_err(|_| anyhow::anyhow!("intel load lock poisoned"))?;
if let Some(db) = self.cached(&key)? {
return Ok((db, evidence));
}
let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
let db = Arc::new(resolved.db);
*self
.cache
.write()
.map_err(|_| anyhow::anyhow!("intel cache poisoned"))? = Some((key, db.clone()));
Ok((db, resolved.evidence))
}
fn cached(&self, key: &str) -> Result<Option<Arc<AdvisoryDb>>> {
let cache = self
.cache
.read()
.map_err(|_| anyhow::anyhow!("intel cache poisoned"))?;
Ok(cache
.as_ref()
.filter(|(k, _)| k == key)
.map(|(_, db)| db.clone()))
}
}