holger-intel 3.1.2

Holger offline vulnerability intelligence: OSV / NVD / CISA KEV normalized into one advisory set, carried across the airgap as a signed holger.update-bundle/v1 (product holger.intel/v1), verified with anti-rollback, and kept in a persistent local advisory database the scanners read. Network-free unless the `fetch` feature is on.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! The secure side's door: verify a carried intel bundle, hold it to the
//! anti-rollback floor, record it in the audit log, and only then make its
//! advisory set the live local database.
//!
//! The order is the one `holger-server bundle import` keeps (S0/B3), and for the
//! same reasons:
//!
//! 1. the bundle file is read ONCE; the bytes that verify are the bytes kept;
//! 2. the release signature is checked from the control members before any
//!    payload is decompressed (`load_bundle_verified`);
//! 3. the full verification runs against the trusted clock and the floor;
//! 4. the floor decides: a lower sequence, a replay with other bytes, or an
//!    absent floor without `first_import` is refused;
//! 5. the audit sink must be ready, and the audit record is written BEFORE the
//!    floor moves and before the new set goes live. A record that cannot be
//!    written undoes what was staged, and the database stays as it was.

use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::container::load_bundle_verified;
use holger_update_bundle::core::BundleCore;
use holger_update_bundle::digest::sha256_hex;
use holger_update_bundle::receipt::ImportReceipt;
use holger_update_bundle::rollback::{AcceptedEntry, AcceptedState, Admissibility, RollbackStore};
use holger_update_bundle::schema::{CORE_JSON, PAYLOAD_PREFIX};
use holger_update_bundle::time::TrustedTime;
use holger_update_bundle::verify::{verify_member_map_with_time, TargetSpec};
use holger_update_bundle::{atomic_write, TrustPolicy};
use serde::{Deserialize, Serialize};

use crate::db::{InstalledIntel, LocalIntelDb, INSTALLED_SCHEMA};
use crate::set::AdvisorySet;
use crate::{ADVISORIES_REL, INTEL_PRODUCT};

/// What to import, where, under which policy.
pub struct ImportRequest<'a> {
    pub bundle: &'a Path,
    pub policy: &'a TrustPolicy,
    pub db: &'a LocalIntelDb,
    /// Establish the anti-rollback floor with this bundle because there is
    /// none. No effect when a floor exists.
    pub first_import: bool,
}

/// The audit record of one import.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImportRecord {
    pub bundle_id: String,
    pub sequence: u64,
    pub supplier: String,
    pub snapshot_at: DateTime<Utc>,
    pub candidate_digest: String,
    pub bundle_sha256: String,
    pub advisories: u64,
    /// `new` or `idempotent-reimport`.
    pub outcome: String,
}

impl ImportRecord {
    /// The detail line an audit event carries.
    pub fn detail(&self) -> String {
        format!(
            "bundle_id={} seq={} snapshot={} digest={} advisories={} outcome={}",
            self.bundle_id,
            self.sequence,
            self.snapshot_at.format("%Y-%m-%d"),
            self.candidate_digest,
            self.advisories,
            self.outcome
        )
    }
}

/// The audit sink an import writes through. The server's audit log in
/// production; a test double in tests.
pub trait ImportAudit {
    /// Refuse when the sink is off or could not be opened. Asked before anything
    /// is written.
    fn ready(&self) -> Result<()>;
    /// Write and flush the record. An error refuses the import.
    fn record(&self, record: &ImportRecord) -> Result<()>;
}

/// The result of an import.
#[derive(Debug, Clone)]
pub struct ImportOutcome {
    pub record: ImportRecord,
    pub installed: InstalledIntel,
    pub stored_bundle: PathBuf,
    pub receipt: PathBuf,
    /// The verifier's checks, the time source first.
    pub checks: Vec<String>,
}

/// An exclusive import lock: `create_new`, removed on drop.
struct Lock(PathBuf);

impl Lock {
    fn acquire(path: PathBuf) -> Result<Self> {
        match std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&path)
        {
            Ok(mut f) => {
                use std::io::Write;
                let _ = writeln!(f, "pid={}", std::process::id());
                Ok(Lock(path))
            }
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => bail!(
                "intel import refused: {} exists, so another import is running or one was \
                 killed. Check no import runs, then remove the lock.",
                path.display()
            ),
            Err(e) => Err(e).with_context(|| format!("create {}", path.display())),
        }
    }
}

impl Drop for Lock {
    fn drop(&mut self) {
        let _ = std::fs::remove_file(&self.0);
    }
}

fn safe_name(s: &str) -> String {
    let out: String = s
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    if out.is_empty() || out == "." || out == ".." {
        "_".into()
    } else {
        out
    }
}

/// The schema of a [`Prepared`] import (the `import-intel` job's record).
pub const PREPARED_SCHEMA: &str = "holger.intel-prepared/v1";

/// **What verifying an intel bundle found** (lane cds4, plan row HOL-CDS-4): everything the
/// intel store needs to take the bundle, and nothing written. [`prepare`] makes it, inside
/// Holger's `import-intel` job; [`commit`] lands it, in the server, after the job's signed
/// result was believed. The advisory set and the bundle's bytes travel beside it, and
/// [`commit`] holds both to the digests named here.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Prepared {
    pub schema: String,
    /// The audit record (bundle id, sequence, supplier, snapshot, digests, outcome).
    pub record: ImportRecord,
    pub product: String,
    pub release_version: String,
    pub release_signer_fingerprint: String,
    /// The SHA-256 of the advisory set (the bundle's own `advisory_snapshot_digest`).
    pub advisories_sha256: String,
    /// The verifier's checks, the time source first.
    pub checks: Vec<String>,
}

/// A [`Prepared`] import with the bytes it names.
#[derive(Debug, Clone)]
pub struct PreparedImport {
    pub prepared: Prepared,
    /// The advisory set (`payload/advisories.json`), as the release signed it.
    pub advisories: Vec<u8>,
    /// The bundle's bytes, the ones that verified.
    pub bundle: Vec<u8>,
}

/// Import one intel bundle. See the module note for the order. [`prepare`], then
/// [`commit`], under the database's import lock.
pub fn import(
    req: &ImportRequest<'_>,
    time: &dyn TrustedTime,
    audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
    let db = req.db;
    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
    let _lock = Lock::acquire(db.lock_path())?;
    let p = prepare(
        req.bundle,
        req.policy,
        &db.state_dir(),
        req.first_import,
        time,
    )?;
    commit_locked(db, &p, time, audit)
}

/// **Verify an intel bundle against `policy` and the anti-rollback floor in `state_dir`**
/// (read only), writing nothing: steps 1 to 4 of the module note. The `import-intel` job runs
/// this over its read-only input; [`import`] runs it in-process.
pub fn prepare(
    bundle: &Path,
    policy: &TrustPolicy,
    state_dir: &Path,
    first_import: bool,
    time: &dyn TrustedTime,
) -> Result<PreparedImport> {
    let raw = std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
    let members = load_bundle_verified(bundle, policy)
        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
    // The bytes that verified are the bytes kept: the file did not change under the check.
    let again =
        std::fs::read(bundle).with_context(|| format!("read bundle {}", bundle.display()))?;
    if sha256_hex(&again) != sha256_hex(&raw) {
        bail!("intel import refused: the bundle changed while it was verified");
    }

    let core = BundleCore::from_json_bytes(
        members
            .get(CORE_JSON)
            .ok_or_else(|| anyhow::anyhow!("intel bundle has no core.json"))?,
    )?;
    if core.product != INTEL_PRODUCT {
        bail!(
            "intel import refused: the bundle's product is {:?}, not {INTEL_PRODUCT:?}. Import \
             update bundles with `bundle import`.",
            core.product
        );
    }

    let rollback = RollbackStore::open(state_dir);
    let floor = rollback.floor(&core.supplier, &core.product)?;
    let state = floor
        .accepted()
        .cloned()
        .unwrap_or_else(AcceptedState::empty);
    let target = TargetSpec {
        product: Some(INTEL_PRODUCT.to_string()),
        model: None,
        revision: None,
    };
    let report = verify_member_map_with_time(&members, policy, &target, Some(&state), time)
        .map_err(|e| anyhow::anyhow!("intel bundle verification failed: {e}"))?;
    let admissibility = floor
        .check_admissible(&core, &report.candidate_digest, first_import)
        .map_err(|e| anyhow::anyhow!("intel import refused: {e}"))?;

    let payload_path = format!("{PAYLOAD_PREFIX}{ADVISORIES_REL}");
    let payload = members
        .get(&payload_path)
        .ok_or_else(|| anyhow::anyhow!("intel bundle has no {payload_path}"))?;
    let payload_sha = sha256_hex(payload);
    if core.policy_result.advisory_snapshot_digest.as_deref() != Some(payload_sha.as_str()) {
        bail!("intel bundle's advisory_snapshot_digest does not name its own advisory set");
    }
    let set = AdvisorySet::from_json_bytes(payload)?;
    let outcome = match admissibility {
        Admissibility::New => "new",
        Admissibility::IdempotentReimport => "idempotent-reimport",
    };
    Ok(PreparedImport {
        prepared: Prepared {
            schema: PREPARED_SCHEMA.to_string(),
            record: ImportRecord {
                bundle_id: core.bundle_id.clone(),
                sequence: core.sequence,
                supplier: core.supplier.clone(),
                snapshot_at: core.created_at,
                candidate_digest: report.candidate_digest.clone(),
                bundle_sha256: sha256_hex(&raw),
                advisories: set.advisories.len() as u64,
                outcome: outcome.to_string(),
            },
            product: core.product.clone(),
            release_version: core.release_version.clone(),
            release_signer_fingerprint: report.release_signer_fingerprint.clone(),
            advisories_sha256: payload_sha,
            checks: report.checks,
        },
        advisories: payload.clone(),
        bundle: raw,
    })
}

/// **Land a [`PreparedImport`] in `db`**: step 5 of the module note. The audit sink must be
/// ready; the record is written BEFORE the floor moves and before the set goes live, and a
/// record that cannot be written undoes what was staged. The bytes are held to the digests
/// the record names, and the advisory set is not parsed here.
pub fn commit(
    db: &LocalIntelDb,
    p: &PreparedImport,
    time: &dyn TrustedTime,
    audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
    std::fs::create_dir_all(db.root()).with_context(|| format!("mkdir {}", db.root().display()))?;
    let _lock = Lock::acquire(db.lock_path())?;
    commit_locked(db, p, time, audit)
}

fn commit_locked(
    db: &LocalIntelDb,
    p: &PreparedImport,
    time: &dyn TrustedTime,
    audit: &dyn ImportAudit,
) -> Result<ImportOutcome> {
    let (prep, payload, raw) = (&p.prepared, &p.advisories, &p.bundle);
    if prep.schema != PREPARED_SCHEMA {
        bail!(
            "intel import refused: the prepared import has schema {:?}, not {PREPARED_SCHEMA:?}",
            prep.schema
        );
    }
    if prep.product != INTEL_PRODUCT {
        bail!(
            "intel import refused: the prepared import is for {:?}, not {INTEL_PRODUCT:?}",
            prep.product
        );
    }
    if sha256_hex(payload) != prep.advisories_sha256 {
        bail!("intel import refused: the advisory set is not the one the verified bundle names");
    }
    if sha256_hex(raw) != prep.record.bundle_sha256 {
        bail!("intel import refused: the bundle's bytes are not the ones that verified");
    }
    let record = prep.record.clone();
    let id = &record.bundle_id;

    audit.ready().map_err(|e| {
        anyhow::anyhow!(
            "intel import refused: the audit log is off or could not be opened ({e:#}), and an \
             import that leaves no audit record is not allowed"
        )
    })?;

    let live_before = db.installed().ok().flatten();
    let gen_file = LocalIntelDb::generation_file(record.sequence);
    let gen_path = db.stage_generation(record.sequence, payload)?;
    let stored = db
        .bundles_dir()
        .join(format!("{}.holger.znippy", safe_name(id)));
    let bundle_was_there = stored.exists();
    if !bundle_was_there {
        atomic_write(&stored, raw).map_err(|e| anyhow::anyhow!("store intel bundle: {e}"))?;
    }
    let undo = || {
        if live_before.as_ref().map(|m| m.advisories_file.as_str()) != Some(gen_file.as_str()) {
            let _ = std::fs::remove_file(&gen_path);
        }
        if !bundle_was_there {
            let _ = std::fs::remove_file(&stored);
        }
    };

    if let Err(e) = audit.record(&record) {
        undo();
        bail!(
            "intel import refused: its audit record could not be written ({e:#}). No audit record \
             means no import; the advisory database and the anti-rollback floor are unchanged."
        );
    }

    let now = time.now().unwrap_or_else(|_| Utc::now());
    let rollback = RollbackStore::open(db.state_dir());
    rollback.record(
        &record.supplier,
        &prep.product,
        &AcceptedEntry {
            sequence: record.sequence,
            bundle_id: id.clone(),
            core_digest: record.candidate_digest.clone(),
            accepted_at: now,
        },
    )?;
    let installed = InstalledIntel {
        schema: INSTALLED_SCHEMA.to_string(),
        bundle_id: id.clone(),
        sequence: record.sequence,
        supplier: record.supplier.clone(),
        snapshot_at: record.snapshot_at,
        imported_at: now,
        candidate_digest: record.candidate_digest.clone(),
        release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
        advisories_file: gen_file,
        advisories_sha256: prep.advisories_sha256.clone(),
        advisories: record.advisories,
    };
    db.activate(&installed)?;

    let receipt = ImportReceipt {
        bundle_id: id.clone(),
        sequence: record.sequence,
        supplier: record.supplier.clone(),
        product: prep.product.clone(),
        release_version: prep.release_version.clone(),
        candidate_digest: record.candidate_digest.clone(),
        release_signer_fingerprint: prep.release_signer_fingerprint.clone(),
        imported_at: now,
        outcome: record.outcome.clone(),
        bundle_sha256: record.bundle_sha256.clone(),
    };
    std::fs::create_dir_all(db.receipts_dir()).ok();
    let receipt_path = db.receipts_dir().join(format!("{}.json", safe_name(id)));
    std::fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt)?)?;

    Ok(ImportOutcome {
        record,
        installed,
        stored_bundle: stored,
        receipt: receipt_path,
        checks: prep.checks.clone(),
    })
}