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 SECRET_SCANNING_PUSH_PROTECTION: &str = "secret-scanning-push-protection";
89 pub const BRANCH_PROTECTION_ADMIN_ENFORCEMENT: &str = "branch-protection-admin-enforcement";
90 pub const DISMISS_STALE_REVIEWS_ON_PUSH: &str = "dismiss-stale-reviews-on-push";
91 pub const ACTIONS_PINNED_DEPENDENCIES: &str = "actions-pinned-dependencies";
92 pub const ENVIRONMENT_PROTECTION_RULES: &str = "environment-protection-rules";
93 pub const CODE_SCANNING_ALERTS_RESOLVED: &str = "code-scanning-alerts-resolved";
94 pub const DEPENDENCY_LICENSE_COMPLIANCE: &str = "dependency-license-compliance";
95 pub const SBOM_ATTESTATION: &str = "sbom-attestation";
96 pub const RELEASE_ASSET_ATTESTATION: &str = "release-asset-attestation";
97 pub const PRIVILEGED_WORKFLOW_DETECTION: &str = "privileged-workflow-detection";
98 pub const WORKFLOW_PERMISSIONS_RESTRICTED: &str = "workflow-permissions-restricted";
99 pub const DEPENDENCY_UPDATE_TOOL: &str = "dependency-update-tool";
100 pub const REPOSITORY_PERMISSIONS_AUDIT: &str = "repository-permissions-audit";
101 pub const DEFAULT_BRANCH_SETTINGS_BASELINE: &str = "default-branch-settings-baseline";
102 pub const SECURITY_TEST_IN_CI: &str = "security-test-in-ci";
103
104 pub const ALL: &[&str] = &[
106 SOURCE_AUTHENTICITY,
107 REVIEW_INDEPENDENCE,
108 BRANCH_HISTORY_INTEGRITY,
109 BRANCH_PROTECTION_ENFORCEMENT,
110 TWO_PARTY_REVIEW,
111 BUILD_PROVENANCE,
112 REQUIRED_STATUS_CHECKS,
113 HOSTED_BUILD_PLATFORM,
114 PROVENANCE_AUTHENTICITY,
115 BUILD_ISOLATION,
116 DEPENDENCY_SIGNATURE,
117 DEPENDENCY_PROVENANCE_CHECK,
118 DEPENDENCY_SIGNER_VERIFIED,
119 DEPENDENCY_COMPLETENESS,
120 CHANGE_REQUEST_SIZE,
121 TEST_COVERAGE,
122 SCOPED_CHANGE,
123 ISSUE_LINKAGE,
124 STALE_REVIEW,
125 DESCRIPTION_QUALITY,
126 MERGE_COMMIT_POLICY,
127 CONVENTIONAL_TITLE,
128 SECURITY_FILE_CHANGE,
129 RELEASE_TRACEABILITY,
130 CODEOWNERS_COVERAGE,
131 SECRET_SCANNING,
132 VULNERABILITY_SCANNING,
133 SECURITY_POLICY,
134 SECRET_SCANNING_PUSH_PROTECTION,
135 BRANCH_PROTECTION_ADMIN_ENFORCEMENT,
136 DISMISS_STALE_REVIEWS_ON_PUSH,
137 ACTIONS_PINNED_DEPENDENCIES,
138 ENVIRONMENT_PROTECTION_RULES,
139 CODE_SCANNING_ALERTS_RESOLVED,
140 DEPENDENCY_LICENSE_COMPLIANCE,
141 SBOM_ATTESTATION,
142 RELEASE_ASSET_ATTESTATION,
143 PRIVILEGED_WORKFLOW_DETECTION,
144 WORKFLOW_PERMISSIONS_RESTRICTED,
145 DEPENDENCY_UPDATE_TOOL,
146 REPOSITORY_PERMISSIONS_AUDIT,
147 DEFAULT_BRANCH_SETTINGS_BASELINE,
148 SECURITY_TEST_IN_CI,
149 ];
150
151 pub fn id(s: &str) -> ControlId {
153 ControlId::new(s)
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum ControlStatus {
161 Satisfied,
162 Violated,
163 Indeterminate,
164 NotApplicable,
165}
166
167impl ControlStatus {
168 pub fn as_str(&self) -> &'static str {
169 match self {
170 Self::Satisfied => "satisfied",
171 Self::Violated => "violated",
172 Self::Indeterminate => "indeterminate",
173 Self::NotApplicable => "not_applicable",
174 }
175 }
176}
177
178impl fmt::Display for ControlStatus {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(self.as_str())
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186pub struct ControlFinding {
187 pub control_id: ControlId,
188 pub status: ControlStatus,
189 pub rationale: String,
190 pub subjects: Vec<String>,
191 pub evidence_gaps: Vec<EvidenceGap>,
192}
193
194impl ControlFinding {
195 pub fn satisfied(
196 control_id: ControlId,
197 rationale: impl Into<String>,
198 subjects: Vec<String>,
199 ) -> Self {
200 Self {
201 control_id,
202 status: ControlStatus::Satisfied,
203 rationale: rationale.into(),
204 subjects,
205 evidence_gaps: Vec::new(),
206 }
207 }
208
209 pub fn violated(
210 control_id: ControlId,
211 rationale: impl Into<String>,
212 subjects: Vec<String>,
213 ) -> Self {
214 Self {
215 control_id,
216 status: ControlStatus::Violated,
217 rationale: rationale.into(),
218 subjects,
219 evidence_gaps: Vec::new(),
220 }
221 }
222
223 pub fn indeterminate(
224 control_id: ControlId,
225 rationale: impl Into<String>,
226 subjects: Vec<String>,
227 evidence_gaps: Vec<EvidenceGap>,
228 ) -> Self {
229 Self {
230 control_id,
231 status: ControlStatus::Indeterminate,
232 rationale: rationale.into(),
233 subjects,
234 evidence_gaps,
235 }
236 }
237
238 pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
239 Self {
240 control_id,
241 status: ControlStatus::NotApplicable,
242 rationale: rationale.into(),
243 subjects: Vec::new(),
244 evidence_gaps: Vec::new(),
245 }
246 }
247
248 pub fn extract_posture(
259 id: ControlId,
260 evidence: &EvidenceBundle,
261 ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
262 match &evidence.repository_posture {
263 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
264 EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
265 id,
266 "Repository posture evidence could not be collected",
267 vec![],
268 gaps.clone(),
269 )]),
270 EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
271 id,
272 "Repository posture not applicable",
273 )]),
274 }
275 }
276}
277
278pub trait Control: Send + Sync {
280 fn id(&self) -> ControlId;
282
283 fn description(&self) -> &'static str {
285 "Custom control"
286 }
287
288 fn tsc_criteria(&self) -> &'static [&'static str] {
291 builtin_tsc_mapping(self.id().as_str())
292 }
293
294 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
296}
297
298pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
300 match id {
301 builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
303 builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
304 builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
305 builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
306 builtin::ISSUE_LINKAGE => &["CC7.2"],
308 builtin::STALE_REVIEW => &["CC7.2"],
309 builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
310 builtin::RELEASE_TRACEABILITY => &["CC7.2"],
311 builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
312 builtin::VULNERABILITY_SCANNING => &["CC7.1"],
313 builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
314 builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
316 builtin::TWO_PARTY_REVIEW => &["CC8.1"],
317 builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
318 builtin::TEST_COVERAGE => &["CC8.1"],
319 builtin::SCOPED_CHANGE => &["CC8.1"],
320 builtin::DESCRIPTION_QUALITY => &["CC8.1"],
321 builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
322 builtin::CONVENTIONAL_TITLE => &["CC8.1"],
323 builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
324 builtin::BUILD_PROVENANCE => &["PI1.4"],
326 builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
327 builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
328 builtin::BUILD_ISOLATION => &["PI1.4"],
329 builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
331 builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
332 builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
333 builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
334 builtin::SECRET_SCANNING_PUSH_PROTECTION => &["CC6.1", "CC6.6"],
336 builtin::BRANCH_PROTECTION_ADMIN_ENFORCEMENT => &["CC6.1", "CC8.1"],
337 builtin::DISMISS_STALE_REVIEWS_ON_PUSH => &["CC8.1"],
338 builtin::ACTIONS_PINNED_DEPENDENCIES => &["CC7.1", "PI1.4"],
339 builtin::ENVIRONMENT_PROTECTION_RULES => &["CC6.1", "CC8.1"],
340 builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
341 builtin::DEPENDENCY_LICENSE_COMPLIANCE => &["CC7.1"],
342 builtin::SBOM_ATTESTATION => &["CC7.1"],
343 builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
344 builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
345 _ => &[],
346 }
347}
348
349pub fn evaluate_all(
351 controls: &[Box<dyn Control>],
352 evidence: &EvidenceBundle,
353) -> Vec<ControlFinding> {
354 let mut findings = Vec::new();
355 for control in controls {
356 findings.extend(control.evaluate(evidence));
357 }
358 findings
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn control_id_display() {
367 let id = ControlId::new("review-independence");
368 assert_eq!(id.to_string(), "review-independence");
369 assert_eq!(id.as_str(), "review-independence");
370 }
371
372 #[test]
373 fn control_id_from_str() {
374 let id: ControlId = "source-authenticity".into();
375 assert_eq!(id.as_str(), "source-authenticity");
376 }
377
378 #[test]
379 fn builtin_ids_are_unique() {
380 let mut seen = std::collections::HashSet::new();
381 for id in builtin::ALL {
382 assert!(seen.insert(id), "duplicate built-in ID: {id}");
383 }
384 }
385}