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::Result;
13use chrono::{Datelike, NaiveDate};
14
15use super::hasher::{hash_tree, sha256_file};
16use super::manifest::Manifest;
17use crate::risks::fold;
18use crate::risks::model::FindingRef;
19use crate::risks::store;
20
21const MANIFEST_FILE: &str = "manifest.json";
22const PREPARE_FILE: &str = "prepare.json";
23const RESULT_FILE: &str = "result.json";
24const PENDING_SENTINEL: &str = ".run-pending";
25const RISKS_DIR: &str = "risks";
26const EVENTS_FILE: &str = "events.jsonl";
27
28/// One verified run.
29#[derive(Debug, Clone)]
30pub struct VerifiedRun {
31    pub control_id: String,
32    pub run_id: String,
33    pub run_dir: PathBuf,
34}
35
36/// Aggregate report over a verification pass.
37#[derive(Debug, Clone, Default)]
38pub struct VerifyReport {
39    pub verified: Vec<VerifiedRun>,
40    pub failures: Vec<VerifyFailure>,
41    /// Risk logs whose chain and finding refs all verified.
42    pub verified_risks: Vec<VerifiedRisk>,
43    /// Risk logs that failed verification (broken chain or unresolvable
44    /// finding ref).
45    pub risk_failures: Vec<RiskFailure>,
46}
47
48/// One verified risk log: its chain walked clean and every `finding_ref`
49/// resolved to a sealed manifest whose recomputed sha matched.
50#[derive(Debug, Clone)]
51pub struct VerifiedRisk {
52    pub risk_id: String,
53    /// Number of `finding_ref`s resolved and hash-checked.
54    pub finding_refs: usize,
55}
56
57/// A risk log that failed verification.
58#[derive(Debug, Clone)]
59pub struct RiskFailure {
60    pub risk_id: String,
61    pub kind: RiskFailureKind,
62    pub detail: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum RiskFailureKind {
67    /// The `events.jsonl` `prev_sha256` chain is broken, a `seq` is
68    /// non-monotonic, the leading event is not `opened`, or a line failed to
69    /// parse — i.e. the log was edited or tampered with.
70    BrokenChain,
71    /// A `finding_ref` did not resolve to a sealed manifest whose recomputed
72    /// sha matched (manifest absent, mutated, or control/run id mismatch).
73    BadFindingRef,
74}
75
76#[derive(Debug, Clone)]
77pub struct VerifyFailure {
78    pub control_id: String,
79    pub run_id: String,
80    pub run_dir: PathBuf,
81    pub kind: FailureKind,
82    pub detail: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum FailureKind {
87    /// Manifest could not be parsed.
88    BadManifest,
89    /// One or more artifact hashes did not match the manifest.
90    ArtifactMismatch,
91    /// An artifact under the run dir, or the manifest file itself, could
92    /// not be read (broken symlink, permission denied, vanished mid-walk,
93    /// disk error). Distinct from ArtifactMismatch so an operator chases
94    /// the I/O problem, not a tampering false alarm.
95    Unreadable,
96    /// `prior_run.manifest_sha256` did not match the recomputed sha of
97    /// the immediately-preceding sealed manifest for that control.
98    BrokenChain,
99    /// Manifest claims a prior run but no prior manifest exists in the
100    /// evidence tree.
101    MissingPrior,
102    /// Manifest is missing a prior_run link but a prior manifest exists.
103    MissingLink,
104}
105
106impl VerifyReport {
107    pub fn is_clean(&self) -> bool {
108        self.failures.is_empty() && self.risk_failures.is_empty()
109    }
110}
111
112/// Verify every run for `control_id`, or every run if `None`. Walks
113/// runs in chronological order (by `run_id`, which is ISO-date-prefixed).
114pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
115    let mut report = VerifyReport::default();
116
117    // Risk-register verification is independent of which control (if any)
118    // the caller scoped evidence verification to; the register binds across
119    // controls, so always walk it when present.
120    verify_risks(root, &mut report);
121
122    let evidence = root.join("evidence");
123    if !evidence.exists() {
124        return Ok(report);
125    }
126
127    // Group manifests by control id.
128    let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
129    for entry in walkdir::WalkDir::new(&evidence) {
130        let entry = entry?;
131        if entry.file_name() != MANIFEST_FILE {
132            continue;
133        }
134        let dir = entry.path().parent().unwrap().to_path_buf();
135        let run_id = dir
136            .file_name()
137            .and_then(|s| s.to_str())
138            .unwrap_or("")
139            .to_string();
140        let cid = dir
141            .parent()
142            .and_then(|p| p.file_name())
143            .and_then(|s| s.to_str())
144            .unwrap_or("")
145            .to_string();
146        if let Some(want) = control_id {
147            if cid != want {
148                continue;
149            }
150        }
151        grouped
152            .entry(cid)
153            .or_default()
154            .push((run_id, entry.path().to_path_buf()));
155    }
156
157    for (cid, mut runs) in grouped {
158        runs.sort_by(|a, b| a.0.cmp(&b.0));
159        let mut prior_sha: Option<String> = None;
160        let mut prior_run_id: Option<String> = None;
161        for (run_id, manifest_path) in &runs {
162            let run_dir = manifest_path.parent().unwrap().to_path_buf();
163
164            // Parse manifest.
165            let bytes = match fs::read(manifest_path) {
166                Ok(b) => b,
167                Err(e) => {
168                    report.failures.push(VerifyFailure {
169                        control_id: cid.clone(),
170                        run_id: run_id.clone(),
171                        run_dir: run_dir.clone(),
172                        kind: FailureKind::BadManifest,
173                        detail: format!("read: {e}"),
174                    });
175                    continue;
176                }
177            };
178            let manifest: Manifest = match serde_json::from_slice(&bytes) {
179                Ok(m) => m,
180                Err(e) => {
181                    report.failures.push(VerifyFailure {
182                        control_id: cid.clone(),
183                        run_id: run_id.clone(),
184                        run_dir: run_dir.clone(),
185                        kind: FailureKind::BadManifest,
186                        detail: format!("parse: {e}"),
187                    });
188                    continue;
189                }
190            };
191
192            // Check artifact hashes match the on-disk files. An I/O error
193            // walking the run dir (one chmod-000'd file is enough to
194            // trigger this) becomes a per-run Unreadable failure rather
195            // than aborting the entire verify pass — otherwise a single
196            // unreadable file in run N silently masks every run after it.
197            match recompute_and_compare(&run_dir, &manifest) {
198                Ok(mismatches) if !mismatches.is_empty() => {
199                    report.failures.push(VerifyFailure {
200                        control_id: cid.clone(),
201                        run_id: run_id.clone(),
202                        run_dir: run_dir.clone(),
203                        kind: FailureKind::ArtifactMismatch,
204                        detail: mismatches.join("; "),
205                    });
206                }
207                Ok(_) => {}
208                Err(io_detail) => {
209                    report.failures.push(VerifyFailure {
210                        control_id: cid.clone(),
211                        run_id: run_id.clone(),
212                        run_dir: run_dir.clone(),
213                        kind: FailureKind::Unreadable,
214                        detail: io_detail,
215                    });
216                }
217            }
218
219            // Check chain link.
220            match (&manifest.prior_run, &prior_sha, &prior_run_id) {
221                (None, None, _) => {}
222                (None, Some(sha), Some(pid)) => {
223                    report.failures.push(VerifyFailure {
224                        control_id: cid.clone(),
225                        run_id: run_id.clone(),
226                        run_dir: run_dir.clone(),
227                        kind: FailureKind::MissingLink,
228                        detail: format!(
229                            "prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
230                        ),
231                    });
232                }
233                (Some(link), None, _) => {
234                    report.failures.push(VerifyFailure {
235                        control_id: cid.clone(),
236                        run_id: run_id.clone(),
237                        run_dir: run_dir.clone(),
238                        kind: FailureKind::MissingPrior,
239                        detail: format!(
240                            "manifest claims prior `{}` but no prior manifest exists",
241                            link.run_id
242                        ),
243                    });
244                }
245                (Some(link), Some(sha), Some(pid))
246                    if &link.manifest_sha256 != sha || &link.run_id != pid =>
247                {
248                    report.failures.push(VerifyFailure {
249                        control_id: cid.clone(),
250                        run_id: run_id.clone(),
251                        run_dir: run_dir.clone(),
252                        kind: FailureKind::BrokenChain,
253                        detail: format!(
254                            "expected prior {pid} sha {sha}; got {} sha {}",
255                            link.run_id, link.manifest_sha256
256                        ),
257                    });
258                }
259                _ => {}
260            }
261
262            // Also tolerate the manifest file itself becoming unreadable
263            // between the directory walk and now (race or transient I/O).
264            match sha256_file(manifest_path) {
265                Ok(sha) => {
266                    prior_sha = Some(sha);
267                    prior_run_id = Some(run_id.clone());
268                    report.verified.push(VerifiedRun {
269                        control_id: cid.clone(),
270                        run_id: run_id.clone(),
271                        run_dir,
272                    });
273                }
274                Err(e) => {
275                    report.failures.push(VerifyFailure {
276                        control_id: cid.clone(),
277                        run_id: run_id.clone(),
278                        run_dir: run_dir.clone(),
279                        kind: FailureKind::Unreadable,
280                        detail: format!("hash manifest: {e}"),
281                    });
282                    // Don't advance prior_sha — keep checking subsequent
283                    // runs against the last-known-good chain anchor.
284                }
285            }
286        }
287    }
288    Ok(report)
289}
290
291/// Returns `Ok(mismatches)` on a successful tree walk (empty Vec means
292/// hashes all matched). Returns `Err(io_detail)` when the walk itself
293/// failed — caller turns that into a `FailureKind::Unreadable` for this
294/// run rather than aborting the whole verify pass.
295fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
296    let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
297    let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
298    let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
299    for h in &on_disk {
300        by_path.insert(h.path.as_str(), h);
301    }
302
303    let mut mismatches: Vec<String> = Vec::new();
304    let claimed: Vec<&super::manifest::Artifact> = manifest
305        .artifacts
306        .iter()
307        .chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
308        .collect();
309
310    for art in &claimed {
311        match by_path.remove(art.path.as_str()) {
312            None => mismatches.push(format!("{}: file missing", art.path)),
313            Some(h) => {
314                if h.sha256 != art.sha256 || h.bytes != art.bytes {
315                    mismatches.push(format!(
316                        "{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
317                        art.path, art.sha256, art.bytes, h.sha256, h.bytes
318                    ));
319                }
320            }
321        }
322    }
323    for leftover in by_path.keys() {
324        mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
325    }
326    Ok(mismatches)
327}
328
329/// Verify the risk register under `<root>/risks/`: for every
330/// `risks/<id>/events.jsonl`, walk its `prev_sha256` chain and resolve every
331/// `finding_ref` to a sealed manifest whose recomputed sha matches.
332///
333/// Pushes results into `report.verified_risks` / `report.risk_failures`. The
334/// register is optional: a root with no `risks/` dir simply contributes no
335/// risk results.
336fn verify_risks(root: &Path, report: &mut VerifyReport) {
337    let risks_dir = root.join(RISKS_DIR);
338    if !risks_dir.exists() {
339        return;
340    }
341
342    // Enumerate `risks/<id>/events.jsonl` dirs, in id order for stable output.
343    let mut risk_ids: Vec<String> = Vec::new();
344    let entries = match fs::read_dir(&risks_dir) {
345        Ok(e) => e,
346        Err(e) => {
347            report.risk_failures.push(RiskFailure {
348                risk_id: RISKS_DIR.to_string(),
349                kind: RiskFailureKind::BrokenChain,
350                detail: format!("read risks dir: {e}"),
351            });
352            return;
353        }
354    };
355    for entry in entries.flatten() {
356        let Ok(ft) = entry.file_type() else { continue };
357        if !ft.is_dir() {
358            continue;
359        }
360        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
361            continue;
362        };
363        if entry.path().join(EVENTS_FILE).exists() {
364            risk_ids.push(name);
365        }
366    }
367    risk_ids.sort();
368
369    for risk_id in risk_ids {
370        // load_events validates seq monotonicity, a leading `opened`, AND the
371        // prev_sha256 chain — any break (including a single edited line) is an
372        // error here, which is exactly the tamper signal we want to surface.
373        let events = match store::load_events(root, &risk_id) {
374            Ok(events) => events,
375            Err(e) => {
376                report.risk_failures.push(RiskFailure {
377                    risk_id: risk_id.clone(),
378                    kind: RiskFailureKind::BrokenChain,
379                    detail: format!("{e:#}"),
380                });
381                continue;
382            }
383        };
384
385        // Resolve every finding_ref accumulated by the fold (the originating
386        // `opened` ref plus every `evidence-linked` / remediated ref).
387        let state = fold::fold(&events);
388        let mut bad: Option<String> = None;
389        for fref in &state.finding_refs {
390            let run_dir = run_dir_for(root, fref);
391            if let Err(e) = store::verify_finding_ref(&run_dir, fref) {
392                bad = Some(format!("{} ({:#})", fref.fingerprint(), e));
393                break;
394            }
395        }
396        match bad {
397            Some(detail) => report.risk_failures.push(RiskFailure {
398                risk_id: risk_id.clone(),
399                kind: RiskFailureKind::BadFindingRef,
400                detail,
401            }),
402            None => report.verified_risks.push(VerifiedRisk {
403                risk_id: risk_id.clone(),
404                finding_refs: state.finding_refs.len(),
405            }),
406        }
407    }
408}
409
410/// Locate the sealed run dir a `finding_ref` points at, mirroring the runner's
411/// layout: `evidence/<year>/<quarter>/<control_id>/<run_id>/`, with year and
412/// quarter derived from the run id's `YYYY-MM-DD` prefix.
413fn run_dir_for(root: &Path, fref: &FindingRef) -> PathBuf {
414    let date = fref
415        .run_id
416        .get(0..10)
417        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
418    let year = fref.run_id.get(0..4).unwrap_or("0000");
419    let quarter = date
420        .map(|d| format!("q{}", (d.month() - 1) / 3 + 1))
421        .unwrap_or_else(|| "q0".to_string());
422    root.join("evidence")
423        .join(year)
424        .join(quarter)
425        .join(&fref.control_id)
426        .join(&fref.run_id)
427}
428
429#[cfg(test)]
430mod risk_tests {
431    use super::*;
432    use crate::risks::model::{FindingRef, Severity};
433    use crate::risks::store;
434    use std::io::Write;
435
436    const CONTROL: &str = "ra-vuln-audit";
437    const RUN_ID: &str = "2026-05-25-run-001";
438
439    /// Seal a minimal but parseable `manifest.json` under the layout
440    /// `verify` expects, returning its sha256 so a finding_ref can bind to it.
441    fn seal_manifest(root: &Path) -> String {
442        let run_dir = root
443            .join("evidence")
444            .join("2026")
445            .join("q2")
446            .join(CONTROL)
447            .join(RUN_ID);
448        fs::create_dir_all(&run_dir).unwrap();
449        let manifest = serde_json::json!({
450            "schema_version": crate::SCHEMA_VERSION,
451            "control_id": CONTROL,
452            "run_id": RUN_ID,
453            "started_at": "2026-05-25T14:00:00Z",
454            "completed_at": "2026-05-25T14:05:00Z",
455            "agent": {
456                "model": "test", "skill": "ra-vuln-audit",
457                "skill_sha256": "0", "control_sha256": "0"
458            },
459            "registry_git_sha": "deadbeef",
460            "scope_layout": "flat",
461            "resolved_scope": [],
462            "artifacts": [],
463            "status": "complete"
464        });
465        let path = run_dir.join("manifest.json");
466        let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
467        fs::write(&path, &bytes).unwrap();
468        // Sanity: the fixture must deserialize as a real Manifest, since
469        // verify_finding_ref parses it.
470        let _: Manifest = serde_json::from_slice(&bytes).unwrap();
471        sha256_file(&path).unwrap()
472    }
473
474    fn finding_ref(manifest_sha256: String) -> FindingRef {
475        FindingRef {
476            control_id: CONTROL.to_string(),
477            run_id: RUN_ID.to_string(),
478            manifest_sha256,
479            finding_id: "S032".to_string(),
480            body_path: Some("findings.md#risk-1".to_string()),
481        }
482    }
483
484    /// Open one risk bound to the sealed manifest via the real store API, so
485    /// its log is a genuinely valid hash chain.
486    fn open_risk(root: &Path, manifest_sha256: String) -> String {
487        let due = NaiveDate::from_ymd_opt(2026, 6, 24).unwrap();
488        let out = store::open(
489            root,
490            finding_ref(manifest_sha256),
491            "S032 — pickle deserialization RCE",
492            Severity::Critical,
493            5,
494            4,
495            vec!["api".to_string()],
496            30,
497            due,
498            "jstockdi",
499            None,
500            None,
501        )
502        .expect("open risk");
503        out.risk_id
504    }
505
506    #[test]
507    fn clean_register_verifies() {
508        let tmp = tempfile::tempdir().unwrap();
509        let root = tmp.path();
510        let sha = seal_manifest(root);
511        let risk_id = open_risk(root, sha);
512
513        let report = verify(root, None).expect("verify");
514        assert!(
515            report.is_clean(),
516            "expected clean verify, got risk failures: {:?}",
517            report.risk_failures
518        );
519        assert_eq!(report.verified_risks.len(), 1);
520        assert_eq!(report.verified_risks[0].risk_id, risk_id);
521        assert_eq!(report.verified_risks[0].finding_refs, 1);
522    }
523
524    #[test]
525    fn no_register_contributes_no_risk_results() {
526        let tmp = tempfile::tempdir().unwrap();
527        let report = verify(tmp.path(), None).expect("verify");
528        assert!(report.verified_risks.is_empty());
529        assert!(report.risk_failures.is_empty());
530    }
531
532    #[test]
533    fn tampered_log_line_breaks_chain() {
534        let tmp = tempfile::tempdir().unwrap();
535        let root = tmp.path();
536        let sha = seal_manifest(root);
537        let risk_id = open_risk(root, sha);
538        // Append a second valid event so the chain has a link to break.
539        store::append(
540            root,
541            &risk_id,
542            crate::risks::model::EventData::Note {
543                text: "investigating".to_string(),
544            },
545            "jstockdi",
546            None,
547            None,
548        )
549        .expect("append note");
550
551        // Edit the FIRST line in place: its title appears only on line 1, so
552        // rewriting a byte there changes line 1's sha — which line 2's stored
553        // prev_sha256 was computed over — breaking the chain without touching
554        // seq order.
555        let events_path = root.join("risks").join(&risk_id).join("events.jsonl");
556        let text = fs::read_to_string(&events_path).unwrap();
557        let tampered = text.replacen("pickle deserialization", "pickle  deserialization", 1);
558        assert_ne!(text, tampered, "tamper must change the bytes");
559        fs::write(&events_path, tampered).unwrap();
560
561        let report = verify(root, None).expect("verify");
562        assert!(!report.is_clean());
563        let f = report
564            .risk_failures
565            .iter()
566            .find(|f| f.risk_id == risk_id)
567            .expect("expected a risk failure for the tampered log");
568        assert_eq!(f.kind, RiskFailureKind::BrokenChain);
569        assert!(report.verified_risks.is_empty());
570    }
571
572    #[test]
573    fn mutated_manifest_breaks_finding_ref() {
574        let tmp = tempfile::tempdir().unwrap();
575        let root = tmp.path();
576        let sha = seal_manifest(root);
577        let risk_id = open_risk(root, sha);
578
579        // Mutate the sealed manifest after the risk bound to it: its
580        // recomputed sha no longer matches the finding_ref.
581        let manifest_path = root
582            .join("evidence/2026/q2")
583            .join(CONTROL)
584            .join(RUN_ID)
585            .join("manifest.json");
586        let mut f = std::fs::OpenOptions::new()
587            .append(true)
588            .open(&manifest_path)
589            .unwrap();
590        f.write_all(b"\n").unwrap();
591        drop(f);
592
593        let report = verify(root, None).expect("verify");
594        assert!(!report.is_clean());
595        let f = report
596            .risk_failures
597            .iter()
598            .find(|f| f.risk_id == risk_id)
599            .expect("expected a risk failure for the mutated manifest");
600        assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
601        assert!(
602            f.detail.contains("ra-vuln-audit:S032"),
603            "detail: {}",
604            f.detail
605        );
606    }
607
608    #[test]
609    fn absent_manifest_breaks_finding_ref() {
610        let tmp = tempfile::tempdir().unwrap();
611        let root = tmp.path();
612        let sha = seal_manifest(root);
613        let risk_id = open_risk(root, sha);
614
615        // Remove the whole sealed run dir the finding_ref points at.
616        let run_dir = root.join("evidence/2026/q2").join(CONTROL).join(RUN_ID);
617        fs::remove_dir_all(&run_dir).unwrap();
618
619        let report = verify(root, None).expect("verify");
620        assert!(!report.is_clean());
621        let f = report
622            .risk_failures
623            .iter()
624            .find(|f| f.risk_id == risk_id)
625            .expect("expected a risk failure for the absent manifest");
626        assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
627    }
628}