Skip to main content

taudit_core/
baselines.rs

1//! Per-pipeline baseline files (`.taudit/baselines/<hash>.json`).
2//!
3//! A *baseline* is a snapshot of the findings present on a pipeline at the
4//! moment it was first onboarded into taudit. Subsequent scans diff against
5//! the baseline so reviewers see only NEW findings; pre-existing findings are
6//! summarised. Baselines are the v0.10 mechanism for adopting taudit on
7//! existing repos without forcing upfront triage of historical findings.
8//!
9//! ## Load-bearing decisions (per design council, 2026-04-26)
10//!
11//! 1. **Layout: one file per pipeline keyed by content hash.** A monolithic
12//!    `.taudit/baseline.json` would merge-conflict on every PR. Per-pipeline
13//!    files (`.taudit/baselines/<sha256>.json`) keep blast radius small.
14//! 2. **Fingerprints reuse `Finding::compute_fingerprint` exactly.** Inventing
15//!    a second hashing scheme is a foot-gun — SARIF, JSON, CloudEvents and
16//!    baselines must agree on what "same finding" means. The shared test
17//!    `baseline_fingerprint_matches_sarif_fingerprint` enforces this.
18//! 3. **Critical findings always exit 1** unless the entry carries
19//!    `severity_override: critical` AND a `reason` AND `expires_at <= 90d`.
20//!    This is the security analyst's non-negotiable: any waiver mechanism
21//!    creates a path for risk to be accepted, so critical waivers must be
22//!    conscious, time-bounded and re-reviewed.
23//! 4. **OSS-friendly default.** No `.taudit/` directory means today's
24//!    behaviour. Baselines are strictly opt-in.
25//!
26//! See `docs/baselines.md` for the full workflow and security guarantees.
27
28use crate::finding::{compute_fingerprint, Finding, Severity};
29use crate::graph::AuthorityGraph;
30use chrono::{DateTime, Duration, Utc};
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use std::path::{Path, PathBuf};
34
35/// Maximum lifetime allowed for a critical-severity waiver. Council's
36/// load-bearing constraint: a critical may only bypass exit-1 if its waiver
37/// expires within this window. Longer expirations are rejected at validation
38/// time (and pruned at diff time).
39pub const MAX_CRITICAL_WAIVER_DAYS: i64 = 90;
40
41/// Minimum length (UTF-8 chars) of the `reason` string on a waiver. Empty,
42/// `wip`, `todo`, `fix later` strings train the wrong muscle memory; force
43/// a sentence's worth of justification.
44pub const MIN_REASON_LENGTH: usize = 10;
45
46/// Schema version emitted by `init` and accepted by `load`. Additive 1.x.y
47/// changes are non-breaking; 2.0.0 means breaking changes.
48pub const BASELINE_SCHEMA_VERSION: &str = "1.0.0";
49
50/// Errors returned by baseline I/O and validation.
51#[derive(Debug, thiserror::Error)]
52pub enum BaselineError {
53    #[error("failed to read baseline {path}: {source}")]
54    Read {
55        path: PathBuf,
56        #[source]
57        source: std::io::Error,
58    },
59    #[error("failed to write baseline {path}: {source}")]
60    Write {
61        path: PathBuf,
62        #[source]
63        source: std::io::Error,
64    },
65    #[error("failed to parse baseline {path}: {source}")]
66    Parse {
67        path: PathBuf,
68        #[source]
69        source: serde_json::Error,
70    },
71    #[error("failed to serialize baseline: {0}")]
72    Serialize(#[from] serde_json::Error),
73    #[error("baseline schema version {found:?} not supported (expected major 1.x.y)")]
74    UnsupportedVersion { found: String },
75    #[error("waiver reason must be at least {min} characters (got {got})")]
76    ReasonTooShort { min: usize, got: usize },
77    #[error("critical-severity override requires expires_at <= {days}d from accepted_at")]
78    CriticalWaiverTooLong { days: i64 },
79    #[error("critical-severity override requires expires_at to be set")]
80    CriticalWaiverNoExpiry,
81    #[error("critical-severity override requires a reason")]
82    CriticalWaiverNoReason,
83}
84
85/// One entry in a baseline. Keyed on `fingerprint` (16-hex SHA-256 truncation
86/// computed by [`compute_fingerprint`](crate::finding::compute_fingerprint)).
87///
88/// Two waiver shapes:
89///
90/// * **Plain pre-existing finding.** `reason_waived`, `severity_override`,
91///   `expires_at` all `None`. The finding existed at `init` time; it is
92///   reported as "pre-existing" rather than a regression. Critical findings
93///   in this shape STILL fail exit-1.
94/// * **Explicit waiver.** `reason_waived` populated. If the original
95///   severity was Critical, `severity_override: "critical"` and
96///   `expires_at <= accepted_at + 90d` are mandatory; otherwise the waiver
97///   is rejected at load time and the critical falls through to exit 1.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct BaselineFinding {
100    /// 16-hex SHA-256 fingerprint matching the SARIF/JSON/CloudEvents value.
101    pub fingerprint: String,
102    /// Snake-case rule id (custom rule id if present, else
103    /// `FindingCategory` snake_case form).
104    pub rule_id: String,
105    /// Severity captured at `init` time. Used for the critical-bypass check.
106    pub severity: Severity,
107    /// When this entry was first added to the baseline (`init` or `accept`).
108    pub first_seen_at: DateTime<Utc>,
109    /// Free-form justification. Required on `accept` (>=10 chars). `None`
110    /// when the entry was bulk-added by `init`.
111    #[serde(skip_serializing_if = "Option::is_none", default)]
112    pub reason_waived: Option<String>,
113    /// Acknowledges that the original severity was Critical and the waiver
114    /// is intentional. Council's hard rule: any critical bypass must declare
115    /// itself with this field; missing == critical falls through to exit 1.
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub severity_override: Option<Severity>,
118    /// Hard deadline. Mandatory for `severity_override: critical`. After
119    /// this timestamp the waiver is treated as expired (logs a warning and
120    /// the underlying finding counts toward exit-1 again).
121    #[serde(skip_serializing_if = "Option::is_none", default)]
122    pub expires_at: Option<DateTime<Utc>>,
123}
124
125impl BaselineFinding {
126    /// True iff this entry waives a critical via the explicit-override
127    /// shape (severity_override + reason + expires_at <= 90d).
128    pub fn is_valid_critical_waiver(&self, now: DateTime<Utc>) -> bool {
129        if self.severity_override != Some(Severity::Critical) {
130            return false;
131        }
132        let Some(expires_at) = self.expires_at else {
133            return false;
134        };
135        if expires_at <= now {
136            return false;
137        }
138        if (expires_at - self.first_seen_at) > Duration::days(MAX_CRITICAL_WAIVER_DAYS) {
139            return false;
140        }
141        matches!(self.reason_waived.as_deref(), Some(r) if r.chars().count() >= MIN_REASON_LENGTH)
142    }
143
144    /// True iff this waiver carries an `expires_at` that has already passed.
145    pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
146        match self.expires_at {
147            Some(t) => t <= now,
148            None => false,
149        }
150    }
151}
152
153/// Tool/version provenance captured at `init`.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct CapturedWith {
156    pub taudit_version: String,
157    /// Free-form description of the rule set at capture time
158    /// (e.g. `"32-builtin"`, `"32-builtin+5-custom"`).
159    pub rules_version: String,
160}
161
162/// One baseline file = one pipeline. Keyed by `pipeline_content_hash` so
163/// renames preserve state and merge conflicts only touch the affected file.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct Baseline {
166    pub schema_version: String,
167    pub pipeline_path: String,
168    /// `sha256:<hex>` of the pipeline file's bytes at `init` time.
169    pub pipeline_content_hash: String,
170    pub captured_at: DateTime<Utc>,
171    pub captured_by: String,
172    pub captured_with: CapturedWith,
173    /// Sorted by `fingerprint` ASC for stable git diffs.
174    pub baseline_findings: Vec<BaselineFinding>,
175}
176
177impl Baseline {
178    /// Load and parse a baseline from disk. Returns `Ok(None)` if `path`
179    /// does not exist (the OSS-friendly default — absent baseline is fine).
180    pub fn load(path: &Path) -> Result<Option<Self>, BaselineError> {
181        if !path.exists() {
182            return Ok(None);
183        }
184        let bytes = std::fs::read(path).map_err(|source| BaselineError::Read {
185            path: path.to_path_buf(),
186            source,
187        })?;
188        let baseline: Baseline =
189            serde_json::from_slice(&bytes).map_err(|source| BaselineError::Parse {
190                path: path.to_path_buf(),
191                source,
192            })?;
193        if !baseline.schema_version.starts_with("1.") {
194            return Err(BaselineError::UnsupportedVersion {
195                found: baseline.schema_version,
196            });
197        }
198        Ok(Some(baseline))
199    }
200
201    /// Write `self` to `path` as pretty JSON with stable key ordering and
202    /// fingerprint-sorted entries. Creates parent directories as needed.
203    pub fn save(&self, path: &Path) -> Result<(), BaselineError> {
204        if let Some(parent) = path.parent() {
205            std::fs::create_dir_all(parent).map_err(|source| BaselineError::Write {
206                path: path.to_path_buf(),
207                source,
208            })?;
209        }
210        let mut sorted = self.clone();
211        sorted
212            .baseline_findings
213            .sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
214        let mut bytes = serde_json::to_vec_pretty(&sorted)?;
215        bytes.push(b'\n');
216        std::fs::write(path, bytes).map_err(|source| BaselineError::Write {
217            path: path.to_path_buf(),
218            source,
219        })?;
220        Ok(())
221    }
222
223    /// Produce a fresh baseline from `current_findings` against `graph`.
224    /// Each entry is a plain pre-existing finding (no waiver fields set).
225    /// `pipeline_path` should be the pipeline's filesystem path as the user
226    /// sees it; `content` is the raw bytes used to derive the content hash.
227    #[allow(clippy::too_many_arguments)]
228    pub fn from_findings(
229        pipeline_path: &str,
230        content: &str,
231        graph: &AuthorityGraph,
232        findings: &[Finding],
233        captured_by: &str,
234        taudit_version: &str,
235        rules_version: &str,
236        now: DateTime<Utc>,
237    ) -> Self {
238        let mut baseline_findings: Vec<BaselineFinding> = findings
239            .iter()
240            .map(|f| BaselineFinding {
241                fingerprint: compute_fingerprint(f, graph),
242                rule_id: rule_id_for(f),
243                severity: f.severity,
244                first_seen_at: now,
245                reason_waived: None,
246                severity_override: None,
247                expires_at: None,
248            })
249            .collect();
250        // Dedup on fingerprint (template instances collapse into one entry).
251        baseline_findings.sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
252        baseline_findings.dedup_by(|a, b| a.fingerprint == b.fingerprint);
253
254        Baseline {
255            schema_version: BASELINE_SCHEMA_VERSION.to_string(),
256            pipeline_path: pipeline_path.to_string(),
257            pipeline_content_hash: compute_pipeline_hash(content),
258            captured_at: now,
259            captured_by: captured_by.to_string(),
260            captured_with: CapturedWith {
261                taudit_version: taudit_version.to_string(),
262                rules_version: rules_version.to_string(),
263            },
264            baseline_findings,
265        }
266    }
267
268    /// Append a single waiver entry. Validates `reason` length and the
269    /// critical-waiver constraints. Returns the inserted/updated entry.
270    /// If an entry with the same fingerprint already exists, it is replaced
271    /// (idempotent re-acceptance with a refreshed reason / expiry).
272    #[allow(clippy::too_many_arguments)]
273    pub fn accept(
274        &mut self,
275        fingerprint: &str,
276        rule_id: &str,
277        severity: Severity,
278        reason: &str,
279        severity_override: Option<Severity>,
280        expires_at: Option<DateTime<Utc>>,
281        now: DateTime<Utc>,
282    ) -> Result<&BaselineFinding, BaselineError> {
283        let reason_chars = reason.chars().count();
284        if reason_chars < MIN_REASON_LENGTH {
285            return Err(BaselineError::ReasonTooShort {
286                min: MIN_REASON_LENGTH,
287                got: reason_chars,
288            });
289        }
290        if severity_override == Some(Severity::Critical) {
291            let Some(exp) = expires_at else {
292                return Err(BaselineError::CriticalWaiverNoExpiry);
293            };
294            if (exp - now) > Duration::days(MAX_CRITICAL_WAIVER_DAYS) {
295                return Err(BaselineError::CriticalWaiverTooLong {
296                    days: MAX_CRITICAL_WAIVER_DAYS,
297                });
298            }
299        }
300        let entry = BaselineFinding {
301            fingerprint: fingerprint.to_string(),
302            rule_id: rule_id.to_string(),
303            severity,
304            first_seen_at: now,
305            reason_waived: Some(reason.to_string()),
306            severity_override,
307            expires_at,
308        };
309        // Replace existing entry with the same fingerprint, else append.
310        if let Some(slot) = self
311            .baseline_findings
312            .iter_mut()
313            .find(|e| e.fingerprint == entry.fingerprint)
314        {
315            *slot = entry;
316        } else {
317            self.baseline_findings.push(entry);
318        }
319        self.baseline_findings
320            .sort_by(|a, b| a.fingerprint.cmp(&b.fingerprint));
321        Ok(self
322            .baseline_findings
323            .iter()
324            .find(|e| e.fingerprint == fingerprint)
325            .expect("just inserted"))
326    }
327}
328
329/// Result of diffing a fresh scan against a baseline. All three buckets
330/// are independently consumable by `verify`'s exit-code logic.
331#[derive(Debug, Clone)]
332pub struct BaselineDiff {
333    /// Findings present in the current scan whose fingerprint is NOT in
334    /// the baseline. These are regressions and drive the verify exit code.
335    pub new: Vec<Finding>,
336    /// Baseline entries whose fingerprint is NOT present in the current
337    /// scan — the underlying issue was fixed (or refactored away). Useful
338    /// for the `taudit baseline diff` summary.
339    pub fixed: Vec<BaselineFinding>,
340    /// Findings present in BOTH the current scan and the baseline. Reported
341    /// for visibility but do not drive exit-1 unless they are critical-
342    /// without-valid-waiver (see [`Self::critical_without_valid_waiver`]).
343    pub preexisting: Vec<Finding>,
344    /// Subset of preexisting baseline entries that carry `reason_waived`.
345    /// Drives the "X waived, Y unwaived" summary.
346    pub waived_count: usize,
347}
348
349impl BaselineDiff {
350    /// Critical findings in `preexisting` whose baseline entry does NOT
351    /// carry a valid critical waiver. These ALWAYS count toward exit 1 —
352    /// the council's load-bearing constraint that critical waivers must be
353    /// explicit, time-bounded, and re-reviewed.
354    pub fn critical_without_valid_waiver(
355        &self,
356        baseline: &Baseline,
357        graph: &AuthorityGraph,
358        now: DateTime<Utc>,
359    ) -> Vec<Finding> {
360        self.preexisting
361            .iter()
362            .filter(|f| f.severity == Severity::Critical)
363            .filter(|f| {
364                let fp = compute_fingerprint(f, graph);
365                match baseline
366                    .baseline_findings
367                    .iter()
368                    .find(|e| e.fingerprint == fp)
369                {
370                    Some(entry) => !entry.is_valid_critical_waiver(now),
371                    None => true, // shouldn't happen — preexisting means present in baseline
372                }
373            })
374            .cloned()
375            .collect()
376    }
377}
378
379/// Diff `current_findings` against `baseline` using the SARIF-equivalent
380/// fingerprint computed from `graph`. Entry point for `verify` and the
381/// `taudit baseline diff` subcommand.
382pub fn diff(
383    current_findings: &[Finding],
384    baseline: &Baseline,
385    graph: &AuthorityGraph,
386) -> BaselineDiff {
387    use std::collections::{HashMap, HashSet};
388
389    let baseline_index: HashMap<&str, &BaselineFinding> = baseline
390        .baseline_findings
391        .iter()
392        .map(|e| (e.fingerprint.as_str(), e))
393        .collect();
394
395    let mut new = Vec::new();
396    let mut preexisting = Vec::new();
397    let mut seen_fingerprints: HashSet<String> = HashSet::new();
398    let mut waived_count = 0usize;
399
400    for finding in current_findings {
401        let fp = compute_fingerprint(finding, graph);
402        seen_fingerprints.insert(fp.clone());
403        match baseline_index.get(fp.as_str()) {
404            Some(entry) => {
405                if entry.reason_waived.is_some() {
406                    waived_count += 1;
407                }
408                preexisting.push(finding.clone());
409            }
410            None => new.push(finding.clone()),
411        }
412    }
413
414    let fixed: Vec<BaselineFinding> = baseline
415        .baseline_findings
416        .iter()
417        .filter(|e| !seen_fingerprints.contains(&e.fingerprint))
418        .cloned()
419        .collect();
420
421    BaselineDiff {
422        new,
423        fixed,
424        preexisting,
425        waived_count,
426    }
427}
428
429/// SHA-256 of `content` formatted as `sha256:<64-hex>`. The `sha256:`
430/// prefix mirrors OCI / git object naming so logs and dashboards can
431/// strip the algorithm tag uniformly.
432pub fn compute_pipeline_hash(content: &str) -> String {
433    let digest = Sha256::digest(content.as_bytes());
434    let mut hex = String::with_capacity(64);
435    for byte in digest.iter() {
436        use std::fmt::Write;
437        let _ = write!(&mut hex, "{byte:02x}");
438    }
439    format!("sha256:{hex}")
440}
441
442/// Default location for per-pipeline baselines, given the working directory.
443/// Returns `<root>/.taudit/baselines/`.
444pub fn baselines_dir(root: &Path) -> PathBuf {
445    root.join(".taudit").join("baselines")
446}
447
448/// Filename for one pipeline's baseline. The `sha256:` prefix is stripped
449/// so the file is portable on filesystems that disallow `:` (Windows NTFS).
450pub fn baseline_filename_for(pipeline_content_hash: &str) -> String {
451    let hex = pipeline_content_hash
452        .strip_prefix("sha256:")
453        .unwrap_or(pipeline_content_hash);
454    format!("{hex}.json")
455}
456
457/// Convenience: full `<root>/.taudit/baselines/<hex>.json` path for the
458/// given content hash.
459pub fn baseline_path_for(root: &Path, pipeline_content_hash: &str) -> PathBuf {
460    baselines_dir(root).join(baseline_filename_for(pipeline_content_hash))
461}
462
463/// Public alias of [`compute_fingerprint`] — re-exported here so the baseline
464/// module is the single import point for "what is the fingerprint of this
465/// finding for baseline purposes". The shared test
466/// `baseline_fingerprint_matches_sarif_fingerprint` asserts these are
467/// byte-equal forever.
468pub fn compute_finding_fingerprint(finding: &Finding, graph: &AuthorityGraph) -> String {
469    compute_fingerprint(finding, graph)
470}
471
472/// Snake-case rule id for `f`. Mirrors the same logic the SARIF reporter
473/// uses (custom rule id from `[id] message` prefix wins over category).
474fn rule_id_for(f: &Finding) -> String {
475    if let Some(id) = f.message.strip_prefix('[') {
476        if let Some(end) = id.find(']') {
477            let candidate = &id[..end];
478            if !candidate.is_empty() {
479                return candidate.to_string();
480            }
481        }
482    }
483    serde_json::to_value(f.category)
484        .ok()
485        .and_then(|v| v.as_str().map(str::to_string))
486        .unwrap_or_else(|| "unknown".to_string())
487}
488
489// ── Tests ───────────────────────────────────────────────────
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::finding::{FindingCategory, FindingExtras, FindingSource, Recommendation};
495    use crate::graph::{AuthorityGraph, NodeKind, PipelineSource, TrustZone};
496
497    fn source(file: &str) -> PipelineSource {
498        PipelineSource {
499            file: file.to_string(),
500            repo: None,
501            git_ref: None,
502            commit_sha: None,
503        }
504    }
505
506    fn make_graph(file: &str) -> (AuthorityGraph, crate::graph::NodeId) {
507        let mut g = AuthorityGraph::new(source(file));
508        let s = g.add_node(NodeKind::Secret, "AWS_KEY", TrustZone::FirstParty);
509        (g, s)
510    }
511
512    fn make_finding(
513        category: FindingCategory,
514        severity: Severity,
515        msg: &str,
516        nodes: Vec<crate::graph::NodeId>,
517    ) -> Finding {
518        Finding {
519            severity,
520            category,
521            path: None,
522            nodes_involved: nodes,
523            message: msg.to_string(),
524            recommendation: Recommendation::Manual {
525                action: "fix".to_string(),
526            },
527            source: FindingSource::BuiltIn,
528            extras: FindingExtras::default(),
529        }
530    }
531
532    fn now() -> DateTime<Utc> {
533        DateTime::parse_from_rfc3339("2026-04-26T12:00:00Z")
534            .unwrap()
535            .with_timezone(&Utc)
536    }
537
538    /// COUNCIL-MANDATED SHARED TEST: baseline fingerprint and SARIF
539    /// fingerprint MUST be byte-equal. If this ever fails, suppression
540    /// across SARIF/JSON/CloudEvents/baseline silently drifts. Non-
541    /// negotiable per the council design doc, Section C, item 5.
542    #[test]
543    fn baseline_fingerprint_matches_sarif_fingerprint() {
544        let (graph, s) = make_graph(".github/workflows/release.yml");
545        let f = make_finding(
546            FindingCategory::AuthorityPropagation,
547            Severity::High,
548            "AWS_KEY reaches third party",
549            vec![s],
550        );
551        let baseline_fp = compute_finding_fingerprint(&f, &graph);
552        let sarif_fp = compute_fingerprint(&f, &graph);
553        assert_eq!(
554            baseline_fp, sarif_fp,
555            "baseline and SARIF fingerprints MUST be byte-equal — do not introduce a second fingerprint scheme"
556        );
557    }
558
559    #[test]
560    fn pipeline_hash_is_deterministic_and_prefixed() {
561        let h = compute_pipeline_hash("on: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n");
562        assert!(h.starts_with("sha256:"));
563        assert_eq!(h.len(), 7 + 64);
564        let h2 = compute_pipeline_hash("on: push\njobs:\n  build:\n    runs-on: ubuntu-latest\n");
565        assert_eq!(h, h2, "same content -> same hash");
566        let h3 = compute_pipeline_hash("on: push\n");
567        assert_ne!(h, h3);
568    }
569
570    #[test]
571    fn init_captures_current_findings() {
572        let (graph, s) = make_graph("ci.yml");
573        let f1 = make_finding(
574            FindingCategory::UnpinnedAction,
575            Severity::High,
576            "actions/checkout@v4 unpinned",
577            vec![s],
578        );
579        let f2 = make_finding(
580            FindingCategory::AuthorityPropagation,
581            Severity::Critical,
582            "AWS_KEY reaches untrusted",
583            vec![s],
584        );
585        let baseline = Baseline::from_findings(
586            "ci.yml",
587            "on: push\n",
588            &graph,
589            &[f1, f2],
590            "ryan@example.com",
591            "0.10.0",
592            "32-builtin",
593            now(),
594        );
595        assert_eq!(baseline.baseline_findings.len(), 2);
596        assert_eq!(baseline.captured_by, "ryan@example.com");
597        assert_eq!(baseline.captured_with.taudit_version, "0.10.0");
598        // Sorted by fingerprint
599        let fps: Vec<&str> = baseline
600            .baseline_findings
601            .iter()
602            .map(|e| e.fingerprint.as_str())
603            .collect();
604        let mut sorted = fps.clone();
605        sorted.sort();
606        assert_eq!(fps, sorted, "entries must be fingerprint-sorted");
607        // No waiver fields on init
608        for entry in &baseline.baseline_findings {
609            assert!(entry.reason_waived.is_none());
610            assert!(entry.severity_override.is_none());
611            assert!(entry.expires_at.is_none());
612        }
613    }
614
615    #[test]
616    fn save_then_load_round_trips() {
617        let dir = tempdir();
618        let (graph, s) = make_graph("ci.yml");
619        let f = make_finding(
620            FindingCategory::UnpinnedAction,
621            Severity::High,
622            "actions/checkout@v4 unpinned",
623            vec![s],
624        );
625        let baseline = Baseline::from_findings(
626            "ci.yml",
627            "x",
628            &graph,
629            &[f],
630            "ryan",
631            "0.10.0",
632            "32-builtin",
633            now(),
634        );
635        let path = dir.join("b.json");
636        baseline.save(&path).expect("save");
637        let loaded = Baseline::load(&path).expect("load").expect("present");
638        assert_eq!(baseline, loaded);
639    }
640
641    #[test]
642    fn load_returns_none_when_absent() {
643        let dir = tempdir();
644        let path = dir.join("does-not-exist.json");
645        assert!(Baseline::load(&path).expect("ok").is_none());
646    }
647
648    #[test]
649    fn accept_rejects_short_reason() {
650        let mut baseline = empty_baseline();
651        let err = baseline
652            .accept(
653                "abcd1234abcd1234",
654                "unpinned_action",
655                Severity::High,
656                "wip",
657                None,
658                None,
659                now(),
660            )
661            .unwrap_err();
662        assert!(matches!(err, BaselineError::ReasonTooShort { .. }));
663    }
664
665    #[test]
666    fn accept_critical_without_expires_is_rejected() {
667        let mut baseline = empty_baseline();
668        let err = baseline
669            .accept(
670                "deadbeefdeadbeef",
671                "trigger_context_mismatch",
672                Severity::Critical,
673                "Threat-modeled exception per ABC-123",
674                Some(Severity::Critical),
675                None, // no expiry
676                now(),
677            )
678            .unwrap_err();
679        assert!(matches!(err, BaselineError::CriticalWaiverNoExpiry));
680    }
681
682    #[test]
683    fn accept_critical_with_expiry_beyond_90d_is_rejected() {
684        let mut baseline = empty_baseline();
685        let too_long = now() + Duration::days(100);
686        let err = baseline
687            .accept(
688                "deadbeefdeadbeef",
689                "trigger_context_mismatch",
690                Severity::Critical,
691                "Threat-modeled exception per ABC-123",
692                Some(Severity::Critical),
693                Some(too_long),
694                now(),
695            )
696            .unwrap_err();
697        assert!(matches!(
698            err,
699            BaselineError::CriticalWaiverTooLong { days: 90 }
700        ));
701    }
702
703    #[test]
704    fn accept_critical_with_valid_expiry_succeeds() {
705        let mut baseline = empty_baseline();
706        let exp = now() + Duration::days(60);
707        baseline
708            .accept(
709                "deadbeefdeadbeef",
710                "trigger_context_mismatch",
711                Severity::Critical,
712                "Threat-modeled exception per ABC-123",
713                Some(Severity::Critical),
714                Some(exp),
715                now(),
716            )
717            .expect("valid critical waiver");
718        let entry = &baseline.baseline_findings[0];
719        assert!(entry.is_valid_critical_waiver(now()));
720        // After the expiry, the waiver no longer protects.
721        assert!(!entry.is_valid_critical_waiver(exp + Duration::seconds(1)));
722    }
723
724    #[test]
725    fn diff_classifies_new_fixed_preexisting() {
726        let (graph, s) = make_graph("ci.yml");
727        let f_old = make_finding(
728            FindingCategory::UnpinnedAction,
729            Severity::High,
730            "actions/checkout@v4 unpinned",
731            vec![s],
732        );
733        let f_unchanged = make_finding(
734            FindingCategory::AuthorityPropagation,
735            Severity::High,
736            "AWS_KEY reaches untrusted",
737            vec![s],
738        );
739        let baseline = Baseline::from_findings(
740            "ci.yml",
741            "x",
742            &graph,
743            &[f_old.clone(), f_unchanged.clone()],
744            "ryan",
745            "0.10.0",
746            "32-builtin",
747            now(),
748        );
749        // Current scan: keep `unchanged`, drop `old`, add `new`.
750        let f_new = make_finding(
751            FindingCategory::OverPrivilegedIdentity,
752            Severity::Medium,
753            "GITHUB_TOKEN over-privileged",
754            vec![s],
755        );
756        let current = vec![f_unchanged.clone(), f_new.clone()];
757        let diff = diff(&current, &baseline, &graph);
758        assert_eq!(diff.new.len(), 1, "f_new is new");
759        assert_eq!(diff.fixed.len(), 1, "f_old was fixed");
760        assert_eq!(diff.preexisting.len(), 1, "f_unchanged preexisting");
761        assert_eq!(diff.waived_count, 0, "no waivers yet");
762    }
763
764    #[test]
765    fn critical_preexisting_without_waiver_blocks_exit_zero() {
766        let (graph, s) = make_graph("ci.yml");
767        let crit = make_finding(
768            FindingCategory::AuthorityPropagation,
769            Severity::Critical,
770            "AWS_KEY reaches untrusted",
771            vec![s],
772        );
773        let baseline = Baseline::from_findings(
774            "ci.yml",
775            "x",
776            &graph,
777            std::slice::from_ref(&crit),
778            "ryan",
779            "0.10.0",
780            "32-builtin",
781            now(),
782        );
783        let diff = diff(&[crit], &baseline, &graph);
784        assert_eq!(diff.preexisting.len(), 1);
785        // Plain pre-existing entry — no severity_override, no waiver — must
786        // STILL force a critical to count toward exit 1.
787        let blockers = diff.critical_without_valid_waiver(&baseline, &graph, now());
788        assert_eq!(
789            blockers.len(),
790            1,
791            "critical without explicit waiver must always block"
792        );
793    }
794
795    #[test]
796    fn critical_with_explicit_waiver_does_not_block() {
797        let (graph, s) = make_graph("ci.yml");
798        let crit = make_finding(
799            FindingCategory::AuthorityPropagation,
800            Severity::Critical,
801            "AWS_KEY reaches untrusted",
802            vec![s],
803        );
804        let mut baseline = Baseline::from_findings(
805            "ci.yml",
806            "x",
807            &graph,
808            std::slice::from_ref(&crit),
809            "ryan",
810            "0.10.0",
811            "32-builtin",
812            now(),
813        );
814        // Promote the entry to a valid critical waiver.
815        let fp = compute_fingerprint(&crit, &graph);
816        baseline
817            .accept(
818                &fp,
819                "authority_propagation",
820                Severity::Critical,
821                "Threat-modeled; documented exception ABC-123",
822                Some(Severity::Critical),
823                Some(now() + Duration::days(60)),
824                now(),
825            )
826            .expect("valid waiver");
827        let diff = diff(&[crit], &baseline, &graph);
828        let blockers = diff.critical_without_valid_waiver(&baseline, &graph, now());
829        assert_eq!(blockers.len(), 0, "valid waiver bypasses exit 1");
830    }
831
832    #[test]
833    fn expired_critical_waiver_no_longer_protects() {
834        let (graph, s) = make_graph("ci.yml");
835        let crit = make_finding(
836            FindingCategory::AuthorityPropagation,
837            Severity::Critical,
838            "AWS_KEY reaches untrusted",
839            vec![s],
840        );
841        let mut baseline = Baseline::from_findings(
842            "ci.yml",
843            "x",
844            &graph,
845            std::slice::from_ref(&crit),
846            "ryan",
847            "0.10.0",
848            "32-builtin",
849            now(),
850        );
851        let fp = compute_fingerprint(&crit, &graph);
852        let exp = now() + Duration::days(30);
853        baseline
854            .accept(
855                &fp,
856                "authority_propagation",
857                Severity::Critical,
858                "Threat-modeled; documented exception ABC-123",
859                Some(Severity::Critical),
860                Some(exp),
861                now(),
862            )
863            .expect("valid waiver");
864        // Time passes past the expiry — the waiver no longer protects.
865        let later = exp + Duration::days(1);
866        let diff = diff(&[crit], &baseline, &graph);
867        let blockers = diff.critical_without_valid_waiver(&baseline, &graph, later);
868        assert_eq!(blockers.len(), 1, "expired waiver must not protect");
869    }
870
871    #[test]
872    fn baselines_dir_and_filename_layout() {
873        let root = std::path::Path::new("/tmp/repo");
874        let dir = baselines_dir(root);
875        assert_eq!(dir, std::path::PathBuf::from("/tmp/repo/.taudit/baselines"));
876        let f = baseline_filename_for("sha256:abcdef0123");
877        assert_eq!(f, "abcdef0123.json");
878        let p = baseline_path_for(root, "sha256:abcdef0123");
879        assert_eq!(
880            p,
881            std::path::PathBuf::from("/tmp/repo/.taudit/baselines/abcdef0123.json")
882        );
883    }
884
885    #[test]
886    fn unsupported_schema_version_rejected() {
887        let dir = tempdir();
888        let path = dir.join("b.json");
889        let body = r#"{"schema_version":"2.0.0","pipeline_path":"x","pipeline_content_hash":"sha256:x","captured_at":"2026-04-26T12:00:00Z","captured_by":"r","captured_with":{"taudit_version":"0.10.0","rules_version":"32-builtin"},"baseline_findings":[]}"#;
890        std::fs::write(&path, body).unwrap();
891        let err = Baseline::load(&path).unwrap_err();
892        assert!(matches!(err, BaselineError::UnsupportedVersion { .. }));
893    }
894
895    // ── Test helpers ─────────────────────────────────────
896
897    fn empty_baseline() -> Baseline {
898        Baseline {
899            schema_version: BASELINE_SCHEMA_VERSION.to_string(),
900            pipeline_path: "ci.yml".to_string(),
901            pipeline_content_hash: compute_pipeline_hash("x"),
902            captured_at: now(),
903            captured_by: "ryan".to_string(),
904            captured_with: CapturedWith {
905                taudit_version: "0.10.0".to_string(),
906                rules_version: "32-builtin".to_string(),
907            },
908            baseline_findings: Vec::new(),
909        }
910    }
911
912    /// Per-process tempdir helper. Avoids pulling in the `tempfile` crate
913    /// just for tests — we control the cleanup ourselves.
914    fn tempdir() -> std::path::PathBuf {
915        let pid = std::process::id();
916        let nanos = std::time::SystemTime::now()
917            .duration_since(std::time::UNIX_EPOCH)
918            .unwrap()
919            .as_nanos();
920        let p = std::env::temp_dir().join(format!("taudit-baselines-test-{pid}-{nanos}"));
921        std::fs::create_dir_all(&p).unwrap();
922        p
923    }
924}