libverify_core/controls/
source_authenticity.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{
3 AuthenticityEvidence, EvidenceBundle, EvidenceGap, EvidenceState, SourceRevision,
4};
5use crate::integrity::signature_severity;
6use crate::verdict::Severity;
7
8pub struct SourceAuthenticityControl;
10
11impl Control for SourceAuthenticityControl {
12 fn id(&self) -> ControlId {
13 builtin::id(builtin::SOURCE_AUTHENTICITY)
14 }
15
16 fn description(&self) -> &'static str {
17 "All commits must carry verified signatures"
18 }
19
20 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
21 let mut findings = Vec::new();
22
23 for change in &evidence.change_requests {
24 findings.push(evaluate_revisions(
25 change.id.to_string(),
26 &change.source_revisions,
27 "change request",
28 ));
29 }
30
31 for batch in &evidence.promotion_batches {
32 findings.push(evaluate_revisions(
33 batch.id.clone(),
34 &batch.source_revisions,
35 "promotion batch",
36 ));
37 }
38
39 if findings.is_empty() {
40 findings.push(ControlFinding::not_applicable(
41 self.id(),
42 "No source revisions were supplied",
43 ));
44 }
45
46 findings
47 }
48}
49
50fn evaluate_revisions(
51 subject: String,
52 revisions_state: &EvidenceState<Vec<SourceRevision>>,
53 scope_label: &str,
54) -> ControlFinding {
55 let mut gaps = revisions_state.gaps().to_vec();
56 let revisions = match revisions_state.value() {
57 Some(revisions) => revisions,
58 None => {
59 return ControlFinding::indeterminate(
60 builtin::id(builtin::SOURCE_AUTHENTICITY),
61 format!("Source authenticity evidence is unavailable for the {scope_label}"),
62 vec![subject],
63 gaps,
64 );
65 }
66 };
67
68 let mut unsigned = Vec::new();
69 for revision in revisions {
70 match authenticity_state(&revision.authenticity) {
71 Ok(auth) => {
72 if !auth.verified {
73 unsigned.push(revision.id.clone());
74 }
75 }
76 Err(mut revision_gaps) => {
77 gaps.append(&mut revision_gaps);
78 }
79 }
80 }
81
82 if !gaps.is_empty() {
83 return ControlFinding::indeterminate(
84 builtin::id(builtin::SOURCE_AUTHENTICITY),
85 format!("Source authenticity cannot be proven for the {scope_label}"),
86 vec![subject],
87 gaps,
88 );
89 }
90
91 match signature_severity(unsigned.len()) {
92 Severity::Pass => ControlFinding::satisfied(
93 builtin::id(builtin::SOURCE_AUTHENTICITY),
94 format!("All revisions in the {scope_label} carry authenticity evidence"),
95 vec![subject],
96 ),
97 _ => ControlFinding::violated(
98 builtin::id(builtin::SOURCE_AUTHENTICITY),
99 format!(
100 "Unsigned or unverified revisions were found in the {scope_label}: {}",
101 unsigned.join(", ")
102 ),
103 vec![subject],
104 ),
105 }
106}
107
108fn authenticity_state(
109 state: &EvidenceState<AuthenticityEvidence>,
110) -> Result<&AuthenticityEvidence, Vec<EvidenceGap>> {
111 match state {
112 EvidenceState::Complete { value } => Ok(value),
113 EvidenceState::Partial { gaps, .. } | EvidenceState::Missing { gaps } => Err(gaps.clone()),
114 EvidenceState::NotApplicable => Err(vec![EvidenceGap::Unsupported {
115 source: "control-normalization".to_string(),
116 capability: "source authenticity not collected for revision".to_string(),
117 }]),
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124 use crate::control::ControlStatus;
125 use crate::evidence::{ChangeRequestId, EvidenceBundle, GovernedChange, SourceRevision};
126
127 fn make_change(verified: bool) -> GovernedChange {
128 GovernedChange {
129 id: ChangeRequestId::new("test", "owner/repo#7"),
130 title: "fix: sign commits".to_string(),
131 summary: None,
132 submitted_by: Some("author".to_string()),
133 changed_assets: EvidenceState::complete(vec![]),
134 approval_decisions: EvidenceState::complete(vec![]),
135 source_revisions: EvidenceState::complete(vec![SourceRevision {
136 id: "deadbeef".to_string(),
137 authored_by: Some("author".to_string()),
138 committed_at: Some("2026-03-15T00:00:00Z".to_string()),
139 merge: false,
140 authenticity: EvidenceState::complete(AuthenticityEvidence::new(
141 verified,
142 Some("gpg".to_string()),
143 )),
144 }]),
145 work_item_refs: EvidenceState::complete(vec![]),
146 }
147 }
148
149 #[test]
150 fn verified_revisions_are_satisfied() {
151 let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
152 change_requests: vec![make_change(true)],
153 promotion_batches: vec![],
154 ..Default::default()
155 });
156 assert_eq!(findings[0].status, ControlStatus::Satisfied);
157 }
158
159 #[test]
160 fn unsigned_revisions_are_violated() {
161 let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
162 change_requests: vec![make_change(false)],
163 promotion_batches: vec![],
164 ..Default::default()
165 });
166 assert_eq!(findings[0].status, ControlStatus::Violated);
167 }
168
169 #[test]
170 fn missing_authenticity_is_indeterminate() {
171 let mut change = make_change(true);
172 change.source_revisions = EvidenceState::complete(vec![SourceRevision {
173 id: "deadbeef".to_string(),
174 authored_by: Some("author".to_string()),
175 committed_at: Some("2026-03-15T00:00:00Z".to_string()),
176 merge: false,
177 authenticity: EvidenceState::not_applicable(),
178 }]);
179
180 let findings = SourceAuthenticityControl.evaluate(&EvidenceBundle {
181 change_requests: vec![change],
182 promotion_batches: vec![],
183 ..Default::default()
184 });
185 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
186 }
187}