1use std::sync::{Arc, Mutex, 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(
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 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 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 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#[derive(Debug, Clone)]
145pub struct ResolvedAdvisories {
146 pub db: AdvisoryDb,
147 pub evidence: IntelEvidence,
148}
149
150pub 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
173pub 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_feed(spec) {
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#[derive(Debug, Clone)]
215pub struct IntelVerdict {
216 pub verdict: Verdict,
217 pub evidence: IntelEvidence,
218}
219
220pub 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
251pub 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
264pub 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
279pub struct LiveIntel {
283 db: LocalIntelDb,
284 rule: StalenessRule,
285 cache: RwLock<Option<(String, Arc<AdvisoryDb>)>>,
286 loading: Mutex<()>,
289}
290
291impl LiveIntel {
292 pub fn new(db: LocalIntelDb, rule: StalenessRule) -> Self {
293 Self {
294 db,
295 rule,
296 cache: RwLock::new(None),
297 loading: Mutex::new(()),
298 }
299 }
300
301 pub fn rule(&self) -> StalenessRule {
302 self.rule
303 }
304
305 pub fn current(&self, now: DateTime<Utc>) -> Result<(Arc<AdvisoryDb>, IntelEvidence)> {
307 let Some(meta) = self.db.installed()? else {
308 let e = IntelEvidence::missing(Some("offline: network refused".into()));
309 return Ok((Arc::new(AdvisoryDb::new(e.rev(), vec![])), e));
310 };
311 let evidence = IntelEvidence::local(
312 &meta,
313 self.rule,
314 now,
315 Some("offline: network refused".into()),
316 );
317 let key = format!("{}#{}", meta.sequence, meta.advisories_sha256);
318 if let Some(db) = self.cached(&key)? {
319 return Ok((db, evidence));
320 }
321 let _one_load = self
322 .loading
323 .lock()
324 .map_err(|_| anyhow::anyhow!("intel load lock poisoned"))?;
325 if let Some(db) = self.cached(&key)? {
326 return Ok((db, evidence));
327 }
328 let resolved = resolve_local(&self.db, self.rule, now, evidence.network.clone())?;
329 let db = Arc::new(resolved.db);
330 *self
331 .cache
332 .write()
333 .map_err(|_| anyhow::anyhow!("intel cache poisoned"))? = Some((key, db.clone()));
334 Ok((db, resolved.evidence))
335 }
336
337 fn cached(&self, key: &str) -> Result<Option<Arc<AdvisoryDb>>> {
338 let cache = self
339 .cache
340 .read()
341 .map_err(|_| anyhow::anyhow!("intel cache poisoned"))?;
342 Ok(cache
343 .as_ref()
344 .filter(|(k, _)| k == key)
345 .map(|(_, db)| db.clone()))
346 }
347}