Skip to main content

secunit_core/evidence/
verifier.rs

1//! Walk every run for a control (or all controls) in chronological order,
2//! recompute artifact hashes, and check each `prior_run.manifest_sha256`
3//! against the recomputed sha of the prior manifest.
4//!
5//! This is the single point of integrity for an assessor; the test
6//! surface here matters more than perf.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, Result};
13
14use super::hasher::{hash_tree, sha256_file};
15use super::manifest::Manifest;
16
17const MANIFEST_FILE: &str = "manifest.json";
18const PREPARE_FILE: &str = "prepare.json";
19const RESULT_FILE: &str = "result.json";
20const PENDING_SENTINEL: &str = ".run-pending";
21
22/// One verified run.
23#[derive(Debug, Clone)]
24pub struct VerifiedRun {
25    pub control_id: String,
26    pub run_id: String,
27    pub run_dir: PathBuf,
28}
29
30/// Aggregate report over a verification pass.
31#[derive(Debug, Clone, Default)]
32pub struct VerifyReport {
33    pub verified: Vec<VerifiedRun>,
34    pub failures: Vec<VerifyFailure>,
35}
36
37#[derive(Debug, Clone)]
38pub struct VerifyFailure {
39    pub control_id: String,
40    pub run_id: String,
41    pub run_dir: PathBuf,
42    pub kind: FailureKind,
43    pub detail: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum FailureKind {
48    /// Manifest could not be parsed.
49    BadManifest,
50    /// One or more artifact hashes did not match the manifest.
51    ArtifactMismatch,
52    /// An artifact under the run dir, or the manifest file itself, could
53    /// not be read (broken symlink, permission denied, vanished mid-walk,
54    /// disk error). Distinct from ArtifactMismatch so an operator chases
55    /// the I/O problem, not a tampering false alarm.
56    Unreadable,
57    /// `prior_run.manifest_sha256` did not match the recomputed sha of
58    /// the immediately-preceding sealed manifest for that control.
59    BrokenChain,
60    /// Manifest claims a prior run but no prior manifest exists in the
61    /// evidence tree.
62    MissingPrior,
63    /// Manifest is missing a prior_run link but a prior manifest exists.
64    MissingLink,
65}
66
67impl VerifyReport {
68    pub fn is_clean(&self) -> bool {
69        self.failures.is_empty()
70    }
71}
72
73/// Verify every run for `control_id`, or every run if `None`. Walks
74/// runs in chronological order (by `run_id`, which is ISO-date-prefixed).
75pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
76    let mut report = VerifyReport::default();
77    let evidence = root.join("evidence");
78    if !evidence.exists() {
79        return Ok(report);
80    }
81
82    // Group manifests by control id.
83    let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
84    for entry in walkdir::WalkDir::new(&evidence) {
85        let entry = entry?;
86        if entry.file_name() != MANIFEST_FILE {
87            continue;
88        }
89        let dir = entry.path().parent().unwrap().to_path_buf();
90        let run_id = dir
91            .file_name()
92            .and_then(|s| s.to_str())
93            .unwrap_or("")
94            .to_string();
95        let cid = dir
96            .parent()
97            .and_then(|p| p.file_name())
98            .and_then(|s| s.to_str())
99            .unwrap_or("")
100            .to_string();
101        if let Some(want) = control_id {
102            if cid != want {
103                continue;
104            }
105        }
106        grouped
107            .entry(cid)
108            .or_default()
109            .push((run_id, entry.path().to_path_buf()));
110    }
111
112    for (cid, mut runs) in grouped {
113        runs.sort_by(|a, b| a.0.cmp(&b.0));
114        let mut prior_sha: Option<String> = None;
115        let mut prior_run_id: Option<String> = None;
116        for (run_id, manifest_path) in &runs {
117            let run_dir = manifest_path.parent().unwrap().to_path_buf();
118
119            // Parse manifest.
120            let bytes = match fs::read(manifest_path) {
121                Ok(b) => b,
122                Err(e) => {
123                    report.failures.push(VerifyFailure {
124                        control_id: cid.clone(),
125                        run_id: run_id.clone(),
126                        run_dir: run_dir.clone(),
127                        kind: FailureKind::BadManifest,
128                        detail: format!("read: {e}"),
129                    });
130                    continue;
131                }
132            };
133            let manifest: Manifest = match serde_json::from_slice(&bytes) {
134                Ok(m) => m,
135                Err(e) => {
136                    report.failures.push(VerifyFailure {
137                        control_id: cid.clone(),
138                        run_id: run_id.clone(),
139                        run_dir: run_dir.clone(),
140                        kind: FailureKind::BadManifest,
141                        detail: format!("parse: {e}"),
142                    });
143                    continue;
144                }
145            };
146
147            // Check artifact hashes match the on-disk files. An I/O error
148            // walking the run dir (one chmod-000'd file is enough to
149            // trigger this) becomes a per-run Unreadable failure rather
150            // than aborting the entire verify pass — otherwise a single
151            // unreadable file in run N silently masks every run after it.
152            match recompute_and_compare(&run_dir, &manifest) {
153                Ok(mismatches) if !mismatches.is_empty() => {
154                    report.failures.push(VerifyFailure {
155                        control_id: cid.clone(),
156                        run_id: run_id.clone(),
157                        run_dir: run_dir.clone(),
158                        kind: FailureKind::ArtifactMismatch,
159                        detail: mismatches.join("; "),
160                    });
161                }
162                Ok(_) => {}
163                Err(io_detail) => {
164                    report.failures.push(VerifyFailure {
165                        control_id: cid.clone(),
166                        run_id: run_id.clone(),
167                        run_dir: run_dir.clone(),
168                        kind: FailureKind::Unreadable,
169                        detail: io_detail,
170                    });
171                }
172            }
173
174            // Check chain link.
175            match (&manifest.prior_run, &prior_sha, &prior_run_id) {
176                (None, None, _) => {}
177                (None, Some(sha), Some(pid)) => {
178                    report.failures.push(VerifyFailure {
179                        control_id: cid.clone(),
180                        run_id: run_id.clone(),
181                        run_dir: run_dir.clone(),
182                        kind: FailureKind::MissingLink,
183                        detail: format!(
184                            "prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
185                        ),
186                    });
187                }
188                (Some(link), None, _) => {
189                    report.failures.push(VerifyFailure {
190                        control_id: cid.clone(),
191                        run_id: run_id.clone(),
192                        run_dir: run_dir.clone(),
193                        kind: FailureKind::MissingPrior,
194                        detail: format!(
195                            "manifest claims prior `{}` but no prior manifest exists",
196                            link.run_id
197                        ),
198                    });
199                }
200                (Some(link), Some(sha), Some(pid))
201                    if &link.manifest_sha256 != sha || &link.run_id != pid =>
202                {
203                    report.failures.push(VerifyFailure {
204                        control_id: cid.clone(),
205                        run_id: run_id.clone(),
206                        run_dir: run_dir.clone(),
207                        kind: FailureKind::BrokenChain,
208                        detail: format!(
209                            "expected prior {pid} sha {sha}; got {} sha {}",
210                            link.run_id, link.manifest_sha256
211                        ),
212                    });
213                }
214                _ => {}
215            }
216
217            // Also tolerate the manifest file itself becoming unreadable
218            // between the directory walk and now (race or transient I/O).
219            match sha256_file(manifest_path) {
220                Ok(sha) => {
221                    prior_sha = Some(sha);
222                    prior_run_id = Some(run_id.clone());
223                    report.verified.push(VerifiedRun {
224                        control_id: cid.clone(),
225                        run_id: run_id.clone(),
226                        run_dir,
227                    });
228                }
229                Err(e) => {
230                    report.failures.push(VerifyFailure {
231                        control_id: cid.clone(),
232                        run_id: run_id.clone(),
233                        run_dir: run_dir.clone(),
234                        kind: FailureKind::Unreadable,
235                        detail: format!("hash manifest: {e}"),
236                    });
237                    // Don't advance prior_sha — keep checking subsequent
238                    // runs against the last-known-good chain anchor.
239                }
240            }
241        }
242    }
243    Ok(report)
244}
245
246/// Returns `Ok(mismatches)` on a successful tree walk (empty Vec means
247/// hashes all matched). Returns `Err(io_detail)` when the walk itself
248/// failed — caller turns that into a `FailureKind::Unreadable` for this
249/// run rather than aborting the whole verify pass.
250fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
251    let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
252    let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
253    let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
254    for h in &on_disk {
255        by_path.insert(h.path.as_str(), h);
256    }
257
258    let mut mismatches: Vec<String> = Vec::new();
259    let claimed: Vec<&super::manifest::Artifact> = manifest
260        .artifacts
261        .iter()
262        .chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
263        .collect();
264
265    for art in &claimed {
266        match by_path.remove(art.path.as_str()) {
267            None => mismatches.push(format!("{}: file missing", art.path)),
268            Some(h) => {
269                if h.sha256 != art.sha256 || h.bytes != art.bytes {
270                    mismatches.push(format!(
271                        "{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
272                        art.path, art.sha256, art.bytes, h.sha256, h.bytes
273                    ));
274                }
275            }
276        }
277    }
278    for leftover in by_path.keys() {
279        mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
280    }
281    Ok(mismatches)
282}
283
284#[allow(dead_code)]
285fn _ensure_anyhow_used() -> anyhow::Error {
286    anyhow!("placeholder")
287}