git_forensic/
signatures.rs1use forensicnomicon::report::{Category, Evidence, Observation, Severity};
10use git_core::{CommitObject, GitHash, GitRepo, Result};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SignatureAnomaly {
16 UnsignedInSignedHistory {
18 commit: GitHash,
20 signed_count: usize,
22 total_count: usize,
24 },
25}
26
27impl SignatureAnomaly {
28 #[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 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 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#[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 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
123pub 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 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}