Skip to main content

libverify_core/controls/
stale_review.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{ApprovalDisposition, EvidenceBundle, EvidenceState, GovernedChange};
3
4/// Parse an RFC 3339 timestamp to epoch seconds for timezone-safe comparison.
5/// Supports both `Z` suffix and `+HH:MM` / `-HH:MM` offsets.
6/// Returns None if the format is unrecognized.
7fn rfc3339_to_epoch_secs(ts: &str) -> Option<i64> {
8    // Minimum: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars
9    if ts.len() < 20 {
10        return None;
11    }
12    let year: i64 = ts[0..4].parse().ok()?;
13    let month: i64 = ts[5..7].parse().ok()?;
14    let day: i64 = ts[8..10].parse().ok()?;
15    let hour: i64 = ts[11..13].parse().ok()?;
16    let min: i64 = ts[14..16].parse().ok()?;
17    let sec: i64 = ts[17..19].parse().ok()?;
18
19    // Days from year 0 to start of this year (simplified, ignoring leap second)
20    let days = days_from_epoch(year, month, day);
21    let base_secs = days * 86400 + hour * 3600 + min * 60 + sec;
22
23    // Parse timezone offset
24    let tz_part = &ts[19..];
25    let offset_secs = if tz_part.starts_with('Z') || tz_part.starts_with('z') {
26        0
27    } else if tz_part.len() >= 6 && (tz_part.starts_with('+') || tz_part.starts_with('-')) {
28        let sign = if tz_part.starts_with('+') { 1 } else { -1 };
29        let oh: i64 = tz_part[1..3].parse().ok()?;
30        let om: i64 = tz_part[4..6].parse().ok()?;
31        sign * (oh * 3600 + om * 60)
32    } else {
33        0 // Assume UTC if no recognizable offset
34    };
35
36    Some(base_secs - offset_secs)
37}
38
39/// Days from Unix epoch (1970-01-01) to a given date.
40fn days_from_epoch(year: i64, month: i64, day: i64) -> i64 {
41    // Adjust for months before March (Rata Die algorithm)
42    let (y, m) = if month <= 2 {
43        (year - 1, month + 9)
44    } else {
45        (year, month - 3)
46    };
47    let era = y / 400;
48    let yoe = y - era * 400;
49    let doy = (153 * m + 2) / 5 + day - 1;
50    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
51    era * 146097 + doe - 719468
52}
53
54/// Compare two RFC 3339 timestamps, handling timezone offsets correctly.
55/// Returns true if `a` is strictly before `b` in absolute (UTC) time.
56fn ts_is_before(a: &str, b: &str) -> bool {
57    match (rfc3339_to_epoch_secs(a), rfc3339_to_epoch_secs(b)) {
58        (Some(ea), Some(eb)) => ea < eb,
59        // Fallback to string comparison if parsing fails
60        _ => a < b,
61    }
62}
63
64/// Detects approval decisions that predate the latest non-merge source revision.
65///
66/// Maps to SOC2 CC7.2: monitoring for anomalies in change governance.
67/// A review approved before subsequent code changes is stale and may not
68/// reflect the final state of the change request.
69pub struct StaleReviewControl;
70
71impl Control for StaleReviewControl {
72    fn id(&self) -> ControlId {
73        builtin::id(builtin::STALE_REVIEW)
74    }
75
76    fn description(&self) -> &'static str {
77        "Approvals must postdate the latest source revision"
78    }
79
80    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
81        if evidence.change_requests.is_empty() {
82            return vec![ControlFinding::not_applicable(
83                self.id(),
84                "No change requests found",
85            )];
86        }
87
88        evidence
89            .change_requests
90            .iter()
91            .map(|cr| evaluate_change(self.id(), cr))
92            .collect()
93    }
94}
95
96fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
97    let cr_subject = cr.id.to_string();
98
99    let approvals = match &cr.approval_decisions {
100        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
101        EvidenceState::Missing { gaps } => {
102            return ControlFinding::indeterminate(
103                id,
104                format!("{cr_subject}: approval evidence could not be collected"),
105                vec![cr_subject],
106                gaps.clone(),
107            );
108        }
109        EvidenceState::NotApplicable => {
110            return ControlFinding::not_applicable(id, "Approval decisions not applicable");
111        }
112    };
113
114    let revisions = match &cr.source_revisions {
115        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
116        EvidenceState::Missing { gaps } => {
117            return ControlFinding::indeterminate(
118                id,
119                format!("{cr_subject}: source revision evidence could not be collected"),
120                vec![cr_subject],
121                gaps.clone(),
122            );
123        }
124        EvidenceState::NotApplicable => {
125            return ControlFinding::not_applicable(id, "Source revisions not applicable");
126        }
127    };
128
129    // Find the latest non-merge, non-bot commit timestamp (UTC-normalized).
130    // Bot-authored commits (bors, mergify, k8s-ci-robot, dependabot, etc.)
131    // are mechanical rebases/merges and should not invalidate prior reviews.
132    let latest_commit_ts = revisions
133        .iter()
134        .filter(|r| !r.merge && !is_bot_author(r.authored_by.as_deref()))
135        .filter_map(|r| r.committed_at.as_deref())
136        .max_by(|a, b| {
137            let ea = rfc3339_to_epoch_secs(a).unwrap_or(0);
138            let eb = rfc3339_to_epoch_secs(b).unwrap_or(0);
139            ea.cmp(&eb)
140        });
141
142    let latest_commit_ts = match latest_commit_ts {
143        Some(ts) => ts,
144        None => {
145            return ControlFinding::not_applicable(
146                id,
147                format!("{cr_subject}: no non-merge commits with timestamps"),
148            );
149        }
150    };
151
152    // Check each approval: if submitted_at < latest_commit_ts (UTC-normalized), it is stale.
153    let stale_approvals: Vec<String> = approvals
154        .iter()
155        .filter(|a| a.disposition == ApprovalDisposition::Approved)
156        .filter(|a| {
157            a.submitted_at
158                .as_deref()
159                .is_some_and(|ts| ts_is_before(ts, latest_commit_ts))
160        })
161        .map(|a| a.actor.clone())
162        .collect();
163
164    if stale_approvals.is_empty() {
165        // Check if there are any approvals at all.
166        let has_approvals = approvals
167            .iter()
168            .any(|a| a.disposition == ApprovalDisposition::Approved);
169        if !has_approvals {
170            return ControlFinding::not_applicable(
171                id,
172                format!("{cr_subject}: no approval decisions to evaluate for staleness"),
173            );
174        }
175        ControlFinding::satisfied(
176            id,
177            format!("{cr_subject}: all approvals postdate the latest source revision"),
178            vec![cr_subject],
179        )
180    } else {
181        ControlFinding::violated(
182            id,
183            format!(
184                "{cr_subject}: {} approval(s) predate the latest commit ({}): {}",
185                stale_approvals.len(),
186                latest_commit_ts,
187                stale_approvals.join(", ")
188            ),
189            stale_approvals,
190        )
191    }
192}
193
194/// Known bot account patterns. These produce mechanical commits
195/// (rebases, merges, version bumps) that should not invalidate prior reviews.
196fn is_bot_author(author: Option<&str>) -> bool {
197    let Some(author) = author else {
198        return false;
199    };
200    let lower = author.to_ascii_lowercase();
201    // Exact matches for well-known merge bots
202    const BOT_NAMES: &[&str] = &[
203        "bors",
204        "bors[bot]",
205        "mergify[bot]",
206        "mergify",
207        "dependabot[bot]",
208        "dependabot",
209        "renovate[bot]",
210        "renovate",
211        "k8s-ci-robot",
212        "greenkeeper[bot]",
213        "github-actions[bot]",
214        "copybara-service[bot]",
215    ];
216    if BOT_NAMES.contains(&lower.as_str()) {
217        return true;
218    }
219    // Suffix heuristic: "[bot]" suffix is GitHub's convention for app accounts
220    lower.ends_with("[bot]")
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::control::ControlStatus;
227    use crate::evidence::{ApprovalDecision, ChangeRequestId, EvidenceGap, SourceRevision};
228
229    fn make_change(
230        approvals: EvidenceState<Vec<ApprovalDecision>>,
231        revisions: EvidenceState<Vec<SourceRevision>>,
232    ) -> GovernedChange {
233        GovernedChange {
234            id: ChangeRequestId::new("test", "owner/repo#1"),
235            title: "test".to_string(),
236            summary: None,
237            submitted_by: None,
238            changed_assets: EvidenceState::not_applicable(),
239            approval_decisions: approvals,
240            source_revisions: revisions,
241            work_item_refs: EvidenceState::not_applicable(),
242        }
243    }
244
245    fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
246        EvidenceBundle {
247            change_requests: changes,
248            ..Default::default()
249        }
250    }
251
252    fn approval(actor: &str, ts: &str) -> ApprovalDecision {
253        ApprovalDecision {
254            actor: actor.to_string(),
255            disposition: ApprovalDisposition::Approved,
256            submitted_at: Some(ts.to_string()),
257        }
258    }
259
260    fn revision(id: &str, ts: &str, merge: bool) -> SourceRevision {
261        SourceRevision {
262            id: id.to_string(),
263            authored_by: Some("dev".to_string()),
264            committed_at: Some(ts.to_string()),
265            merge,
266            authenticity: EvidenceState::not_applicable(),
267        }
268    }
269
270    #[test]
271    fn not_applicable_when_no_changes() {
272        let findings = StaleReviewControl.evaluate(&EvidenceBundle::default());
273        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
274    }
275
276    #[test]
277    fn satisfied_when_approval_postdates_latest_commit() {
278        let cr = make_change(
279            EvidenceState::complete(vec![approval("reviewer", "2026-03-15T12:00:00Z")]),
280            EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
281        );
282        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
283        assert_eq!(findings[0].status, ControlStatus::Satisfied);
284    }
285
286    #[test]
287    fn violated_when_approval_predates_latest_commit() {
288        let cr = make_change(
289            EvidenceState::complete(vec![approval("reviewer", "2026-03-15T10:00:00Z")]),
290            EvidenceState::complete(vec![revision("abc", "2026-03-15T12:00:00Z", false)]),
291        );
292        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
293        assert_eq!(findings[0].status, ControlStatus::Violated);
294        assert!(findings[0].rationale.contains("reviewer"));
295    }
296
297    #[test]
298    fn ignores_merge_commits_for_latest_timestamp() {
299        let cr = make_change(
300            EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
301            EvidenceState::complete(vec![
302                revision("abc", "2026-03-15T10:00:00Z", false),
303                revision("merge", "2026-03-15T14:00:00Z", true),
304            ]),
305        );
306        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
307        assert_eq!(findings[0].status, ControlStatus::Satisfied);
308    }
309
310    #[test]
311    fn indeterminate_when_approvals_missing() {
312        let cr = make_change(
313            EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
314                source: "github".to_string(),
315                subject: "reviews".to_string(),
316                detail: "API error".to_string(),
317            }]),
318            EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
319        );
320        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
321        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
322    }
323
324    #[test]
325    fn not_applicable_when_no_approvals() {
326        let cr = make_change(
327            EvidenceState::complete(vec![]),
328            EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
329        );
330        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
331        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
332    }
333
334    #[test]
335    fn ignores_bot_commits_for_latest_timestamp() {
336        // bors rebases after approval — the bot commit should not invalidate the review
337        let mut bot_rev = revision("bot-abc", "2026-03-15T14:00:00Z", false);
338        bot_rev.authored_by = Some("bors".to_string());
339        let cr = make_change(
340            EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
341            EvidenceState::complete(vec![
342                revision("abc", "2026-03-15T10:00:00Z", false),
343                bot_rev,
344            ]),
345        );
346        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
347        assert_eq!(findings[0].status, ControlStatus::Satisfied);
348    }
349
350    #[test]
351    fn ignores_github_app_bot_commits() {
352        let mut bot_rev = revision("bot-abc", "2026-03-15T14:00:00Z", false);
353        bot_rev.authored_by = Some("dependabot[bot]".to_string());
354        let cr = make_change(
355            EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
356            EvidenceState::complete(vec![
357                revision("abc", "2026-03-15T10:00:00Z", false),
358                bot_rev,
359            ]),
360        );
361        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
362        assert_eq!(findings[0].status, ControlStatus::Satisfied);
363    }
364
365    #[test]
366    fn bot_author_detection() {
367        assert!(is_bot_author(Some("bors")));
368        assert!(is_bot_author(Some("Bors")));
369        assert!(is_bot_author(Some("k8s-ci-robot")));
370        assert!(is_bot_author(Some("dependabot[bot]")));
371        assert!(is_bot_author(Some("custom-app[bot]")));
372        assert!(!is_bot_author(Some("developer")));
373        assert!(!is_bot_author(None));
374    }
375
376    #[test]
377    fn timezone_aware_comparison_utc_vs_offset() {
378        // Approval at 02:54 UTC, commit at 10:34+08:00 = 02:34 UTC
379        // Approval is AFTER commit → not stale
380        assert!(!ts_is_before(
381            "2026-03-24T02:54:37Z",
382            "2026-03-24T10:34:00+08:00"
383        ));
384        // Reverse: commit at 02:34 UTC is before approval at 02:54 UTC
385        assert!(ts_is_before(
386            "2026-03-24T10:34:00+08:00",
387            "2026-03-24T02:54:37Z"
388        ));
389    }
390
391    #[test]
392    fn timezone_aware_same_tz() {
393        assert!(ts_is_before("2026-03-15T10:00:00Z", "2026-03-15T12:00:00Z"));
394        assert!(!ts_is_before(
395            "2026-03-15T12:00:00Z",
396            "2026-03-15T10:00:00Z"
397        ));
398    }
399
400    #[test]
401    fn timezone_aware_negative_offset() {
402        // 10:00-05:00 = 15:00 UTC, which is after 14:00 UTC
403        assert!(!ts_is_before(
404            "2026-03-15T10:00:00-05:00",
405            "2026-03-15T14:00:00Z"
406        ));
407        assert!(ts_is_before(
408            "2026-03-15T14:00:00Z",
409            "2026-03-15T10:00:00-05:00"
410        ));
411    }
412
413    #[test]
414    fn satisfied_when_approval_after_offset_commit() {
415        // Real k8s scenario: approval at 02:54 UTC, commit at 10:34+08:00 (=02:34 UTC)
416        let cr = make_change(
417            EvidenceState::complete(vec![approval("reviewer", "2026-03-24T02:54:37Z")]),
418            EvidenceState::complete(vec![revision("abc", "2026-03-24T10:34:00+08:00", false)]),
419        );
420        let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
421        assert_eq!(findings[0].status, ControlStatus::Satisfied);
422    }
423}