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 AsRef<str> for ControlId {
33 fn as_ref(&self) -> &str {
34 &self.0
35 }
36}
37
38impl std::borrow::Borrow<str> for ControlId {
39 fn borrow(&self) -> &str {
40 &self.0
41 }
42}
43
44impl From<&str> for ControlId {
45 fn from(s: &str) -> Self {
46 Self(s.to_string())
47 }
48}
49
50impl From<String> for ControlId {
51 fn from(s: String) -> Self {
52 Self(s)
53 }
54}
55
56pub mod builtin {
59 use super::ControlId;
60
61 pub const SOURCE_AUTHENTICITY: &str = "source-authenticity";
63 pub const REVIEW_INDEPENDENCE: &str = "review-independence";
64 pub const BRANCH_HISTORY_INTEGRITY: &str = "branch-history-integrity";
65 pub const BRANCH_PROTECTION_ENFORCEMENT: &str = "branch-protection-enforcement";
66 pub const TWO_PARTY_REVIEW: &str = "two-party-review";
67
68 pub const BUILD_PROVENANCE: &str = "build-provenance";
70 pub const REQUIRED_STATUS_CHECKS: &str = "required-status-checks";
71 pub const HOSTED_BUILD_PLATFORM: &str = "hosted-build-platform";
72 pub const PROVENANCE_AUTHENTICITY: &str = "provenance-authenticity";
73 pub const BUILD_ISOLATION: &str = "build-isolation";
74
75 pub const DEPENDENCY_SIGNATURE: &str = "dependency-signature";
77 pub const DEPENDENCY_PROVENANCE_CHECK: &str = "dependency-provenance";
78 pub const DEPENDENCY_SIGNER_VERIFIED: &str = "dependency-signer-verified";
79 pub const DEPENDENCY_COMPLETENESS: &str = "dependency-completeness";
80
81 pub const CHANGE_REQUEST_SIZE: &str = "change-request-size";
83 pub const TEST_COVERAGE: &str = "test-coverage";
84 pub const SCOPED_CHANGE: &str = "scoped-change";
85 pub const ISSUE_LINKAGE: &str = "issue-linkage";
86 pub const STALE_REVIEW: &str = "stale-review";
87 pub const DESCRIPTION_QUALITY: &str = "description-quality";
88 pub const MERGE_COMMIT_POLICY: &str = "merge-commit-policy";
89 pub const CONVENTIONAL_TITLE: &str = "conventional-title";
90 pub const SECURITY_FILE_CHANGE: &str = "security-file-change";
91 pub const RELEASE_TRACEABILITY: &str = "release-traceability";
92
93 pub const CODEOWNERS_COVERAGE: &str = "codeowners-coverage";
95 pub const SECRET_SCANNING: &str = "secret-scanning";
96 pub const VULNERABILITY_SCANNING: &str = "vulnerability-scanning";
97 pub const SECURITY_POLICY: &str = "security-policy";
98
99 pub const SECRET_SCANNING_PUSH_PROTECTION: &str = "secret-scanning-push-protection";
101 pub const BRANCH_PROTECTION_ADMIN_ENFORCEMENT: &str = "branch-protection-admin-enforcement";
102 pub const DISMISS_STALE_REVIEWS_ON_PUSH: &str = "dismiss-stale-reviews-on-push";
103 pub const ACTIONS_PINNED_DEPENDENCIES: &str = "actions-pinned-dependencies";
104 pub const ENVIRONMENT_PROTECTION_RULES: &str = "environment-protection-rules";
105 pub const CODE_SCANNING_ALERTS_RESOLVED: &str = "code-scanning-alerts-resolved";
106 pub const DEPENDENCY_LICENSE_COMPLIANCE: &str = "dependency-license-compliance";
107 pub const SBOM_ATTESTATION: &str = "sbom-attestation";
108 pub const RELEASE_ASSET_ATTESTATION: &str = "release-asset-attestation";
109 pub const PRIVILEGED_WORKFLOW_DETECTION: &str = "privileged-workflow-detection";
110 pub const WORKFLOW_PERMISSIONS_RESTRICTED: &str = "workflow-permissions-restricted";
111 pub const DEPENDENCY_UPDATE_TOOL: &str = "dependency-update-tool";
112 pub const REPOSITORY_PERMISSIONS_AUDIT: &str = "repository-permissions-audit";
113 pub const DEFAULT_BRANCH_SETTINGS_BASELINE: &str = "default-branch-settings-baseline";
114 pub const SECURITY_TEST_IN_CI: &str = "security-test-in-ci";
115 pub const PROTECTED_TAGS: &str = "protected-tags";
116
117 pub const ALL: &[&str] = &[
119 SOURCE_AUTHENTICITY,
120 REVIEW_INDEPENDENCE,
121 BRANCH_HISTORY_INTEGRITY,
122 BRANCH_PROTECTION_ENFORCEMENT,
123 TWO_PARTY_REVIEW,
124 BUILD_PROVENANCE,
125 REQUIRED_STATUS_CHECKS,
126 HOSTED_BUILD_PLATFORM,
127 PROVENANCE_AUTHENTICITY,
128 BUILD_ISOLATION,
129 DEPENDENCY_SIGNATURE,
130 DEPENDENCY_PROVENANCE_CHECK,
131 DEPENDENCY_SIGNER_VERIFIED,
132 DEPENDENCY_COMPLETENESS,
133 CHANGE_REQUEST_SIZE,
134 TEST_COVERAGE,
135 SCOPED_CHANGE,
136 ISSUE_LINKAGE,
137 STALE_REVIEW,
138 DESCRIPTION_QUALITY,
139 MERGE_COMMIT_POLICY,
140 CONVENTIONAL_TITLE,
141 SECURITY_FILE_CHANGE,
142 RELEASE_TRACEABILITY,
143 CODEOWNERS_COVERAGE,
144 SECRET_SCANNING,
145 VULNERABILITY_SCANNING,
146 SECURITY_POLICY,
147 SECRET_SCANNING_PUSH_PROTECTION,
148 BRANCH_PROTECTION_ADMIN_ENFORCEMENT,
149 DISMISS_STALE_REVIEWS_ON_PUSH,
150 ACTIONS_PINNED_DEPENDENCIES,
151 ENVIRONMENT_PROTECTION_RULES,
152 CODE_SCANNING_ALERTS_RESOLVED,
153 DEPENDENCY_LICENSE_COMPLIANCE,
154 SBOM_ATTESTATION,
155 RELEASE_ASSET_ATTESTATION,
156 PRIVILEGED_WORKFLOW_DETECTION,
157 WORKFLOW_PERMISSIONS_RESTRICTED,
158 DEPENDENCY_UPDATE_TOOL,
159 REPOSITORY_PERMISSIONS_AUDIT,
160 DEFAULT_BRANCH_SETTINGS_BASELINE,
161 SECURITY_TEST_IN_CI,
162 PROTECTED_TAGS,
163 ];
164
165 pub fn id(s: &str) -> ControlId {
167 ControlId::new(s)
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ControlStatus {
175 Satisfied,
176 Violated,
177 Indeterminate,
178 NotApplicable,
179}
180
181impl ControlStatus {
182 pub fn as_str(&self) -> &'static str {
183 match self {
184 Self::Satisfied => "satisfied",
185 Self::Violated => "violated",
186 Self::Indeterminate => "indeterminate",
187 Self::NotApplicable => "not_applicable",
188 }
189 }
190}
191
192impl fmt::Display for ControlStatus {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 f.write_str(self.as_str())
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct ControlFinding {
201 pub control_id: ControlId,
202 pub status: ControlStatus,
203 pub rationale: String,
204 pub subjects: Vec<String>,
205 pub evidence_gaps: Vec<EvidenceGap>,
206}
207
208impl ControlFinding {
209 pub fn satisfied(
210 control_id: ControlId,
211 rationale: impl Into<String>,
212 subjects: Vec<String>,
213 ) -> Self {
214 Self {
215 control_id,
216 status: ControlStatus::Satisfied,
217 rationale: rationale.into(),
218 subjects,
219 evidence_gaps: Vec::new(),
220 }
221 }
222
223 pub fn violated(
224 control_id: ControlId,
225 rationale: impl Into<String>,
226 subjects: Vec<String>,
227 ) -> Self {
228 Self {
229 control_id,
230 status: ControlStatus::Violated,
231 rationale: rationale.into(),
232 subjects,
233 evidence_gaps: Vec::new(),
234 }
235 }
236
237 pub fn indeterminate(
238 control_id: ControlId,
239 rationale: impl Into<String>,
240 subjects: Vec<String>,
241 evidence_gaps: Vec<EvidenceGap>,
242 ) -> Self {
243 Self {
244 control_id,
245 status: ControlStatus::Indeterminate,
246 rationale: rationale.into(),
247 subjects,
248 evidence_gaps,
249 }
250 }
251
252 pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
253 Self {
254 control_id,
255 status: ControlStatus::NotApplicable,
256 rationale: rationale.into(),
257 subjects: Vec::new(),
258 evidence_gaps: Vec::new(),
259 }
260 }
261
262 pub fn extract_posture(
273 id: ControlId,
274 evidence: &EvidenceBundle,
275 ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
276 match &evidence.repository_posture {
277 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
278 EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
279 id,
280 "Repository posture evidence could not be collected",
281 vec![],
282 gaps.clone(),
283 )]),
284 EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
285 id,
286 "Repository posture not applicable",
287 )]),
288 }
289 }
290}
291
292pub trait Control: Send + Sync {
294 fn id(&self) -> ControlId;
296
297 fn description(&self) -> &'static str {
299 "Custom control"
300 }
301
302 fn tsc_criteria(&self) -> &'static [&'static str] {
305 builtin_tsc_mapping(self.id().as_str())
306 }
307
308 fn remediation_hint(&self) -> Option<&'static str> {
310 builtin_remediation_hint(self.id().as_str())
311 }
312
313 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
315}
316
317pub fn builtin_remediation_hint(id: &str) -> Option<&'static str> {
319 match id {
320 builtin::SOURCE_AUTHENTICITY => Some("Sign commits: git config commit.gpgsign true"),
321 builtin::REVIEW_INDEPENDENCE => {
322 Some("Ensure PRs are reviewed by someone other than the author")
323 }
324 builtin::BRANCH_HISTORY_INTEGRITY => {
325 Some("Use linear history (rebase/squash, avoid merge commits)")
326 }
327 builtin::BRANCH_PROTECTION_ENFORCEMENT => {
328 Some("Enable branch protection rules at Settings > Branches")
329 }
330 builtin::TWO_PARTY_REVIEW => {
331 Some("Require at least 2 reviewers in branch protection rules")
332 }
333 builtin::REQUIRED_STATUS_CHECKS => {
334 Some("Add required status checks in branch protection rules")
335 }
336 builtin::BUILD_PROVENANCE => {
337 Some("Generate SLSA provenance with slsa-framework/slsa-github-generator")
338 }
339 builtin::HOSTED_BUILD_PLATFORM => Some("Use GitHub-hosted runners instead of self-hosted"),
340 builtin::PROVENANCE_AUTHENTICITY => {
341 Some("Verify build provenance signatures with cosign/slsa-verifier")
342 }
343 builtin::BUILD_ISOLATION => Some("Ensure builds run in ephemeral, isolated environments"),
344 builtin::DEPENDENCY_SIGNATURE => {
345 Some("Use signed dependencies; verify with cosign or sigstore")
346 }
347 builtin::DEPENDENCY_PROVENANCE_CHECK => {
348 Some("Ensure dependencies publish SLSA provenance attestations")
349 }
350 builtin::DEPENDENCY_SIGNER_VERIFIED => {
351 Some("Verify dependency signers against a trusted list")
352 }
353 builtin::DEPENDENCY_COMPLETENESS => {
354 Some("Ensure all transitive dependencies have provenance")
355 }
356 builtin::CHANGE_REQUEST_SIZE => Some(
357 "Keep PRs small and focused; split large changes. Monorepo cross-package PRs may false-positive here -- use --exclude change-request-size",
358 ),
359 builtin::TEST_COVERAGE => Some(
360 "Add or update tests for changed source files. Dependency-only PRs may false-positive here -- use --exclude test-coverage",
361 ),
362 builtin::SCOPED_CHANGE => Some(
363 "Limit PR to a single logical change; split unrelated changes. In monorepos, features spanning multiple packages are expected -- use --exclude scoped-change",
364 ),
365 builtin::ISSUE_LINKAGE => Some(
366 "Reference an issue in the PR body: Fixes #123 or Closes #456. Bot PRs (Dependabot/Renovate) don't link issues -- use --exclude issue-linkage",
367 ),
368 builtin::DESCRIPTION_QUALITY => {
369 Some("Add a meaningful PR description explaining the change")
370 }
371 builtin::MERGE_COMMIT_POLICY => {
372 Some("Use squash or rebase merge strategy instead of merge commits")
373 }
374 builtin::CONVENTIONAL_TITLE => Some(
375 "Use Conventional Commits format: type(scope): description. Bot PRs use their own title format -- use --exclude conventional-title",
376 ),
377 builtin::STALE_REVIEW => Some("Re-request review if changes were pushed after approval"),
378 builtin::SECURITY_FILE_CHANGE => {
379 Some("Security-sensitive file changes require additional review")
380 }
381 builtin::RELEASE_TRACEABILITY => Some("Link release to merged PRs and resolved issues"),
382 builtin::CODEOWNERS_COVERAGE => Some("Add a CODEOWNERS file to define code ownership"),
383 builtin::SECRET_SCANNING => {
384 Some("Enable secret scanning at Settings > Code security and analysis")
385 }
386 builtin::VULNERABILITY_SCANNING => {
387 Some("Enable Dependabot alerts at Settings > Code security and analysis")
388 }
389 builtin::SECURITY_POLICY => {
390 Some("Add a SECURITY.md file with vulnerability reporting instructions")
391 }
392 builtin::SECRET_SCANNING_PUSH_PROTECTION => {
393 Some("Enable push protection at Settings > Code security > Secret scanning")
394 }
395 builtin::BRANCH_PROTECTION_ADMIN_ENFORCEMENT => {
396 Some("Enable 'Include administrators' in branch protection rules")
397 }
398 builtin::DISMISS_STALE_REVIEWS_ON_PUSH => {
399 Some("Enable 'Dismiss stale pull request approvals when new commits are pushed'")
400 }
401 builtin::ACTIONS_PINNED_DEPENDENCIES => {
402 Some("Pin GitHub Actions to full commit SHAs instead of tags")
403 }
404 builtin::ENVIRONMENT_PROTECTION_RULES => {
405 Some("Configure environment protection rules at Settings > Environments")
406 }
407 builtin::CODE_SCANNING_ALERTS_RESOLVED => {
408 Some("Resolve open code scanning alerts at Security > Code scanning alerts")
409 }
410 builtin::DEPENDENCY_LICENSE_COMPLIANCE => {
411 Some("Review dependency licenses; remove or replace copyleft dependencies")
412 }
413 builtin::SBOM_ATTESTATION => {
414 Some("Generate SBOM with gh attestation or anchore/sbom-action in CI")
415 }
416 builtin::RELEASE_ASSET_ATTESTATION => {
417 Some("Attest release assets with gh attestation or sigstore/cosign")
418 }
419 builtin::PRIVILEGED_WORKFLOW_DETECTION => {
420 Some("Avoid pull_request_target with checkout of PR code in workflows")
421 }
422 builtin::WORKFLOW_PERMISSIONS_RESTRICTED => {
423 Some("Set default workflow permissions to 'Read' at Settings > Actions > General")
424 }
425 builtin::DEPENDENCY_UPDATE_TOOL => Some(
426 "Add .github/dependabot.yml or renovate.json to enable automated dependency updates",
427 ),
428 builtin::REPOSITORY_PERMISSIONS_AUDIT => {
429 Some("Reduce admin count (<= 3), use team-based access instead of direct collaborators")
430 }
431 builtin::DEFAULT_BRANCH_SETTINGS_BASELINE => Some(
432 "Enable branch protection, admin enforcement, and stale review dismissal on default branch",
433 ),
434 builtin::SECURITY_TEST_IN_CI => {
435 Some("Add CodeQL or Semgrep to GitHub Actions: github/codeql-action/analyze")
436 }
437 builtin::PROTECTED_TAGS => {
438 Some("Add tag protection rules at Settings > Tags to prevent unauthorized releases")
439 }
440 _ => None,
441 }
442}
443
444pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
446 match id {
447 builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
449 builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
450 builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
451 builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
452 builtin::ISSUE_LINKAGE => &["CC7.2"],
454 builtin::STALE_REVIEW => &["CC7.2"],
455 builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
456 builtin::RELEASE_TRACEABILITY => &["CC7.2"],
457 builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
458 builtin::VULNERABILITY_SCANNING => &["CC7.1"],
459 builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
460 builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
462 builtin::TWO_PARTY_REVIEW => &["CC8.1"],
463 builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
464 builtin::TEST_COVERAGE => &["CC8.1"],
465 builtin::SCOPED_CHANGE => &["CC8.1"],
466 builtin::DESCRIPTION_QUALITY => &["CC8.1"],
467 builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
468 builtin::CONVENTIONAL_TITLE => &["CC8.1"],
469 builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
470 builtin::BUILD_PROVENANCE => &["PI1.4"],
472 builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
473 builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
474 builtin::BUILD_ISOLATION => &["PI1.4"],
475 builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
477 builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
478 builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
479 builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
480 builtin::SECRET_SCANNING_PUSH_PROTECTION => &["CC6.1", "CC6.6"],
482 builtin::BRANCH_PROTECTION_ADMIN_ENFORCEMENT => &["CC6.1", "CC8.1"],
483 builtin::DISMISS_STALE_REVIEWS_ON_PUSH => &["CC8.1"],
484 builtin::ACTIONS_PINNED_DEPENDENCIES => &["CC7.1", "PI1.4"],
485 builtin::ENVIRONMENT_PROTECTION_RULES => &["CC6.1", "CC8.1"],
486 builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
487 builtin::DEPENDENCY_LICENSE_COMPLIANCE => &["CC7.1"],
488 builtin::SBOM_ATTESTATION => &["CC7.1"],
489 builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
490 builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
491 _ => &[],
492 }
493}
494
495pub fn evaluate_all(
497 controls: &[Box<dyn Control>],
498 evidence: &EvidenceBundle,
499) -> Vec<ControlFinding> {
500 let mut findings = Vec::new();
501 for control in controls {
502 findings.extend(control.evaluate(evidence));
503 }
504 findings
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn control_id_display() {
513 let id = ControlId::new("review-independence");
514 assert_eq!(id.to_string(), "review-independence");
515 assert_eq!(id.as_str(), "review-independence");
516 }
517
518 #[test]
519 fn control_id_from_str() {
520 let id: ControlId = "source-authenticity".into();
521 assert_eq!(id.as_str(), "source-authenticity");
522 }
523
524 #[test]
525 fn all_builtins_have_remediation_hints() {
526 for id in builtin::ALL {
527 assert!(
528 builtin_remediation_hint(id).is_some(),
529 "missing remediation hint for built-in control: {id}"
530 );
531 }
532 }
533
534 #[test]
535 fn builtin_ids_are_unique() {
536 let mut seen = std::collections::HashSet::new();
537 for id in builtin::ALL {
538 assert!(seen.insert(id), "duplicate built-in ID: {id}");
539 }
540 }
541}