1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::evidence::{EvidenceBundle, EvidenceGap, EvidenceState, RepositoryPosture};
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct ControlId(String);
15
16impl ControlId {
17 pub fn new(id: impl Into<String>) -> Self {
18 Self(id.into())
19 }
20
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24}
25
26impl fmt::Display for ControlId {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 f.write_str(&self.0)
29 }
30}
31
32impl From<&str> for ControlId {
33 fn from(s: &str) -> Self {
34 Self(s.to_string())
35 }
36}
37
38impl From<String> for ControlId {
39 fn from(s: String) -> Self {
40 Self(s)
41 }
42}
43
44pub mod builtin {
47 use super::ControlId;
48
49 pub const SOURCE_AUTHENTICITY: &str = "source-authenticity";
51 pub const REVIEW_INDEPENDENCE: &str = "review-independence";
52 pub const BRANCH_HISTORY_INTEGRITY: &str = "branch-history-integrity";
53 pub const BRANCH_PROTECTION_ENFORCEMENT: &str = "branch-protection-enforcement";
54 pub const TWO_PARTY_REVIEW: &str = "two-party-review";
55
56 pub const BUILD_PROVENANCE: &str = "build-provenance";
58 pub const REQUIRED_STATUS_CHECKS: &str = "required-status-checks";
59 pub const HOSTED_BUILD_PLATFORM: &str = "hosted-build-platform";
60 pub const PROVENANCE_AUTHENTICITY: &str = "provenance-authenticity";
61 pub const BUILD_ISOLATION: &str = "build-isolation";
62
63 pub const DEPENDENCY_SIGNATURE: &str = "dependency-signature";
65 pub const DEPENDENCY_PROVENANCE_CHECK: &str = "dependency-provenance";
66 pub const DEPENDENCY_SIGNER_VERIFIED: &str = "dependency-signer-verified";
67 pub const DEPENDENCY_COMPLETENESS: &str = "dependency-completeness";
68
69 pub const CHANGE_REQUEST_SIZE: &str = "change-request-size";
71 pub const TEST_COVERAGE: &str = "test-coverage";
72 pub const SCOPED_CHANGE: &str = "scoped-change";
73 pub const ISSUE_LINKAGE: &str = "issue-linkage";
74 pub const STALE_REVIEW: &str = "stale-review";
75 pub const DESCRIPTION_QUALITY: &str = "description-quality";
76 pub const MERGE_COMMIT_POLICY: &str = "merge-commit-policy";
77 pub const CONVENTIONAL_TITLE: &str = "conventional-title";
78 pub const SECURITY_FILE_CHANGE: &str = "security-file-change";
79 pub const RELEASE_TRACEABILITY: &str = "release-traceability";
80
81 pub const CODEOWNERS_COVERAGE: &str = "codeowners-coverage";
83 pub const SECRET_SCANNING: &str = "secret-scanning";
84 pub const VULNERABILITY_SCANNING: &str = "vulnerability-scanning";
85 pub const SECURITY_POLICY: &str = "security-policy";
86
87 pub const ALL: &[&str] = &[
89 SOURCE_AUTHENTICITY,
90 REVIEW_INDEPENDENCE,
91 BRANCH_HISTORY_INTEGRITY,
92 BRANCH_PROTECTION_ENFORCEMENT,
93 TWO_PARTY_REVIEW,
94 BUILD_PROVENANCE,
95 REQUIRED_STATUS_CHECKS,
96 HOSTED_BUILD_PLATFORM,
97 PROVENANCE_AUTHENTICITY,
98 BUILD_ISOLATION,
99 DEPENDENCY_SIGNATURE,
100 DEPENDENCY_PROVENANCE_CHECK,
101 DEPENDENCY_SIGNER_VERIFIED,
102 DEPENDENCY_COMPLETENESS,
103 CHANGE_REQUEST_SIZE,
104 TEST_COVERAGE,
105 SCOPED_CHANGE,
106 ISSUE_LINKAGE,
107 STALE_REVIEW,
108 DESCRIPTION_QUALITY,
109 MERGE_COMMIT_POLICY,
110 CONVENTIONAL_TITLE,
111 SECURITY_FILE_CHANGE,
112 RELEASE_TRACEABILITY,
113 CODEOWNERS_COVERAGE,
114 SECRET_SCANNING,
115 VULNERABILITY_SCANNING,
116 SECURITY_POLICY,
117 ];
118
119 pub fn id(s: &str) -> ControlId {
121 ControlId::new(s)
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "snake_case")]
128pub enum ControlStatus {
129 Satisfied,
130 Violated,
131 Indeterminate,
132 NotApplicable,
133}
134
135impl ControlStatus {
136 pub fn as_str(&self) -> &'static str {
137 match self {
138 Self::Satisfied => "satisfied",
139 Self::Violated => "violated",
140 Self::Indeterminate => "indeterminate",
141 Self::NotApplicable => "not_applicable",
142 }
143 }
144}
145
146impl fmt::Display for ControlStatus {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 f.write_str(self.as_str())
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct ControlFinding {
155 pub control_id: ControlId,
156 pub status: ControlStatus,
157 pub rationale: String,
158 pub subjects: Vec<String>,
159 pub evidence_gaps: Vec<EvidenceGap>,
160}
161
162impl ControlFinding {
163 pub fn satisfied(
164 control_id: ControlId,
165 rationale: impl Into<String>,
166 subjects: Vec<String>,
167 ) -> Self {
168 Self {
169 control_id,
170 status: ControlStatus::Satisfied,
171 rationale: rationale.into(),
172 subjects,
173 evidence_gaps: Vec::new(),
174 }
175 }
176
177 pub fn violated(
178 control_id: ControlId,
179 rationale: impl Into<String>,
180 subjects: Vec<String>,
181 ) -> Self {
182 Self {
183 control_id,
184 status: ControlStatus::Violated,
185 rationale: rationale.into(),
186 subjects,
187 evidence_gaps: Vec::new(),
188 }
189 }
190
191 pub fn indeterminate(
192 control_id: ControlId,
193 rationale: impl Into<String>,
194 subjects: Vec<String>,
195 evidence_gaps: Vec<EvidenceGap>,
196 ) -> Self {
197 Self {
198 control_id,
199 status: ControlStatus::Indeterminate,
200 rationale: rationale.into(),
201 subjects,
202 evidence_gaps,
203 }
204 }
205
206 pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
207 Self {
208 control_id,
209 status: ControlStatus::NotApplicable,
210 rationale: rationale.into(),
211 subjects: Vec::new(),
212 evidence_gaps: Vec::new(),
213 }
214 }
215
216 pub fn extract_posture(
227 id: ControlId,
228 evidence: &EvidenceBundle,
229 ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
230 match &evidence.repository_posture {
231 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
232 EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
233 id,
234 "Repository posture evidence could not be collected",
235 vec![],
236 gaps.clone(),
237 )]),
238 EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
239 id,
240 "Repository posture not applicable",
241 )]),
242 }
243 }
244}
245
246pub trait Control: Send + Sync {
248 fn id(&self) -> ControlId;
250
251 fn description(&self) -> &'static str {
253 "Custom control"
254 }
255
256 fn tsc_criteria(&self) -> &'static [&'static str] {
259 builtin_tsc_mapping(self.id().as_str())
260 }
261
262 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
264}
265
266pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
268 match id {
269 builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
271 builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
272 builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
273 builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
274 builtin::ISSUE_LINKAGE => &["CC7.2"],
276 builtin::STALE_REVIEW => &["CC7.2"],
277 builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
278 builtin::RELEASE_TRACEABILITY => &["CC7.2"],
279 builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
280 builtin::VULNERABILITY_SCANNING => &["CC7.1"],
281 builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
282 builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
284 builtin::TWO_PARTY_REVIEW => &["CC8.1"],
285 builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
286 builtin::TEST_COVERAGE => &["CC8.1"],
287 builtin::SCOPED_CHANGE => &["CC8.1"],
288 builtin::DESCRIPTION_QUALITY => &["CC8.1"],
289 builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
290 builtin::CONVENTIONAL_TITLE => &["CC8.1"],
291 builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
292 builtin::BUILD_PROVENANCE => &["PI1.4"],
294 builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
295 builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
296 builtin::BUILD_ISOLATION => &["PI1.4"],
297 builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
299 builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
300 builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
301 builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
302 _ => &[],
303 }
304}
305
306pub fn evaluate_all(
308 controls: &[Box<dyn Control>],
309 evidence: &EvidenceBundle,
310) -> Vec<ControlFinding> {
311 let mut findings = Vec::new();
312 for control in controls {
313 findings.extend(control.evaluate(evidence));
314 }
315 findings
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn control_id_display() {
324 let id = ControlId::new("review-independence");
325 assert_eq!(id.to_string(), "review-independence");
326 assert_eq!(id.as_str(), "review-independence");
327 }
328
329 #[test]
330 fn control_id_from_str() {
331 let id: ControlId = "source-authenticity".into();
332 assert_eq!(id.as_str(), "source-authenticity");
333 }
334
335 #[test]
336 fn builtin_ids_are_unique() {
337 let mut seen = std::collections::HashSet::new();
338 for id in builtin::ALL {
339 assert!(seen.insert(id), "duplicate built-in ID: {id}");
340 }
341 }
342}