Skip to main content

git_forensic/
signatures.rs

1//! Signature-policy analysis: an unsigned commit in an otherwise-signed history.
2//!
3//! When a repository's reachable history is predominantly signed, the absence of
4//! a signature on a particular commit is a break in the prevailing signing
5//! policy — consistent with a commit injected or forged outside the normal
6//! signed workflow. It is a lead an examiner follows, never a verdict: a
7//! developer may simply have forgotten to sign.
8
9use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{CommitObject, GitHash, GitRepo, Result};
11
12/// A signing-policy anomaly observed across a set of commits.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SignatureAnomaly {
16    /// An unsigned commit within a predominantly-signed history.
17    UnsignedInSignedHistory {
18        /// The unsigned commit's object hash.
19        commit: GitHash,
20        /// How many commits in the audited set were signed.
21        signed_count: usize,
22        /// Total commits in the audited set.
23        total_count: usize,
24    },
25}
26
27impl SignatureAnomaly {
28    /// The stable, published anomaly code.
29    #[must_use]
30    pub fn code(&self) -> &'static str {
31        match self {
32            Self::UnsignedInSignedHistory { .. } => "GIT-UNSIGNED-IN-SIGNED-HISTORY",
33        }
34    }
35}
36
37impl Observation for SignatureAnomaly {
38    fn severity(&self) -> Option<Severity> {
39        match self {
40            // A real policy break, but a forgotten `-S` is a common benign cause,
41            // so it is graded Medium, not High.
42            Self::UnsignedInSignedHistory { .. } => Some(Severity::Medium),
43        }
44    }
45
46    fn code(&self) -> &'static str {
47        SignatureAnomaly::code(self)
48    }
49
50    fn category(&self) -> Category {
51        // A break in cryptographic signing policy is an Integrity signal.
52        Category::Integrity
53    }
54
55    fn note(&self) -> String {
56        match self {
57            Self::UnsignedInSignedHistory {
58                signed_count,
59                total_count,
60                ..
61            } => format!(
62                "commit is unsigned while {signed_count} of {total_count} reachable \
63                 commits are signed; consistent with a commit injected outside the \
64                 prevailing signing policy (a forgotten signature is a benign cause)"
65            ),
66        }
67    }
68
69    fn evidence(&self) -> Vec<Evidence> {
70        match self {
71            Self::UnsignedInSignedHistory {
72                commit,
73                signed_count,
74                total_count,
75            } => vec![
76                Evidence {
77                    field: "commit".into(),
78                    value: commit.to_hex(),
79                    location: None,
80                },
81                Evidence {
82                    field: "signed_count".into(),
83                    value: signed_count.to_string(),
84                    location: None,
85                },
86                Evidence {
87                    field: "total_count".into(),
88                    value: total_count.to_string(),
89                    location: None,
90                },
91            ],
92        }
93    }
94}
95
96/// Audit a set of commits for the unsigned-in-signed-history anomaly (pure).
97///
98/// The set is "predominantly signed" when at least one commit is signed AND a
99/// strict majority (`signed > total / 2`) are signed. Only then is an unsigned
100/// commit anomalous; a fully- or mostly-unsigned history implies no signing
101/// policy to break, so nothing is flagged.
102#[must_use]
103pub fn audit_signatures(commits: &[CommitObject]) -> Vec<SignatureAnomaly> {
104    let total_count = commits.len();
105    let signed_count = commits.iter().filter(|c| c.is_signed).count();
106
107    // Predominantly signed: at least one signature and a strict majority.
108    if signed_count == 0 || signed_count * 2 <= total_count {
109        return Vec::new();
110    }
111
112    commits
113        .iter()
114        .filter(|c| !c.is_signed)
115        .map(|c| SignatureAnomaly::UnsignedInSignedHistory {
116            commit: c.hash,
117            signed_count,
118            total_count,
119        })
120        .collect()
121}
122
123/// Walk every commit reachable from `from` (first-parent) and audit their
124/// signatures for the unsigned-in-signed-history anomaly.
125///
126/// # Errors
127/// Propagates any [`git_core`] read error encountered while walking commits.
128pub fn audit_signatures_repo(repo: &GitRepo, from: GitHash) -> Result<Vec<SignatureAnomaly>> {
129    let mut commits = Vec::new();
130    for commit in repo.walk_commits(from) {
131        commits.push(commit?);
132    }
133    Ok(audit_signatures(&commits))
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use git_core::Signature;
140
141    fn sig() -> Signature {
142        Signature {
143            name: "A".into(),
144            email: "a@b.x".into(),
145            timestamp: 100,
146            tz_offset_secs: 0,
147        }
148    }
149
150    fn commit(hex: &str, is_signed: bool) -> CommitObject {
151        CommitObject {
152            hash: GitHash::from_hex(hex).unwrap(),
153            tree: GitHash::from_hex("89abcdef0123456789abcdef0123456789abcdef").unwrap(),
154            parents: vec![],
155            author: sig(),
156            committer: sig(),
157            message: "m".into(),
158            is_signed,
159        }
160    }
161
162    const A: &str = "0123456789abcdef0123456789abcdef01234567";
163    const B: &str = "1123456789abcdef0123456789abcdef01234567";
164    const C: &str = "2123456789abcdef0123456789abcdef01234567";
165
166    #[test]
167    fn flags_the_lone_unsigned_in_a_signed_majority() {
168        let commits = vec![commit(A, true), commit(B, true), commit(C, false)];
169        let found = audit_signatures(&commits);
170        assert_eq!(found.len(), 1);
171        let SignatureAnomaly::UnsignedInSignedHistory {
172            commit,
173            signed_count,
174            total_count,
175        } = &found[0];
176        assert_eq!(commit.to_hex(), C);
177        assert_eq!(*signed_count, 2);
178        assert_eq!(*total_count, 3);
179    }
180
181    #[test]
182    fn no_findings_when_all_signed() {
183        let commits = vec![commit(A, true), commit(B, true)];
184        assert!(audit_signatures(&commits).is_empty());
185    }
186
187    #[test]
188    fn no_findings_when_all_unsigned() {
189        let commits = vec![commit(A, false), commit(B, false)];
190        assert!(audit_signatures(&commits).is_empty());
191    }
192
193    #[test]
194    fn no_findings_without_a_signed_majority() {
195        // Exactly half signed is not a strict majority → no policy in evidence.
196        let commits = vec![commit(A, true), commit(B, false)];
197        assert!(audit_signatures(&commits).is_empty());
198    }
199
200    #[test]
201    fn empty_set_is_clean() {
202        assert!(audit_signatures(&[]).is_empty());
203    }
204}