1use 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
26pub const FINDING_STALE: &str = "HOLGER-INTEL-STALE";
28pub const FINDING_EXPIRED: &str = "HOLGER-INTEL-EXPIRED";
30pub const FINDING_MISSING: &str = "HOLGER-INTEL-MISSING";
32
33#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct IntelEvidence {
44 pub source: String,
46 pub bundle_id: Option<String>,
47 pub sequence: Option<u64>,
48 pub snapshot_date: Option<String>,
50 pub age_days: Option<i64>,
51 pub staleness: Staleness,
52 pub advisories: u64,
53 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 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 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 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#[derive(Debug, Clone)]
130pub struct ResolvedAdvisories {
131 pub db: AdvisoryDb,
132 pub evidence: IntelEvidence,
133}
134
135pub 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
147pub 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#[derive(Debug, Clone)]
182pub struct IntelVerdict {
183 pub verdict: Verdict,
184 pub evidence: IntelEvidence,
185}
186
187pub 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
212pub 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
220pub 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
235pub 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 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}