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