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 CODE_SCANNING_ALERTS_RESOLVED: &str = "code-scanning-alerts-resolved";
101 pub const RELEASE_ASSET_ATTESTATION: &str = "release-asset-attestation";
102 pub const PRIVILEGED_WORKFLOW_DETECTION: &str = "privileged-workflow-detection";
103 pub const SECURITY_TEST_IN_CI: &str = "security-test-in-ci";
104
105 pub const AGENT_SPEC_CONFORMANCE: &str = "agent-spec-conformance";
107 pub const PRIVILEGED_OPERATION_AUDIT: &str = "privileged-operation-audit";
108
109 pub const ALL: &[&str] = &[
111 SOURCE_AUTHENTICITY,
112 REVIEW_INDEPENDENCE,
113 BRANCH_HISTORY_INTEGRITY,
114 BRANCH_PROTECTION_ENFORCEMENT,
115 TWO_PARTY_REVIEW,
116 BUILD_PROVENANCE,
117 REQUIRED_STATUS_CHECKS,
118 HOSTED_BUILD_PLATFORM,
119 PROVENANCE_AUTHENTICITY,
120 BUILD_ISOLATION,
121 DEPENDENCY_SIGNATURE,
122 DEPENDENCY_PROVENANCE_CHECK,
123 DEPENDENCY_SIGNER_VERIFIED,
124 DEPENDENCY_COMPLETENESS,
125 CHANGE_REQUEST_SIZE,
126 TEST_COVERAGE,
127 SCOPED_CHANGE,
128 ISSUE_LINKAGE,
129 STALE_REVIEW,
130 DESCRIPTION_QUALITY,
131 MERGE_COMMIT_POLICY,
132 CONVENTIONAL_TITLE,
133 SECURITY_FILE_CHANGE,
134 RELEASE_TRACEABILITY,
135 CODEOWNERS_COVERAGE,
136 SECRET_SCANNING,
137 VULNERABILITY_SCANNING,
138 SECURITY_POLICY,
139 CODE_SCANNING_ALERTS_RESOLVED,
140 RELEASE_ASSET_ATTESTATION,
141 PRIVILEGED_WORKFLOW_DETECTION,
142 SECURITY_TEST_IN_CI,
143 AGENT_SPEC_CONFORMANCE,
144 PRIVILEGED_OPERATION_AUDIT,
145 ];
146
147 pub fn id(s: &str) -> ControlId {
149 ControlId::new(s)
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ControlStatus {
157 Satisfied,
158 Violated,
159 Indeterminate,
160 NotApplicable,
161}
162
163impl ControlStatus {
164 pub fn as_str(&self) -> &'static str {
165 match self {
166 Self::Satisfied => "satisfied",
167 Self::Violated => "violated",
168 Self::Indeterminate => "indeterminate",
169 Self::NotApplicable => "not_applicable",
170 }
171 }
172}
173
174impl fmt::Display for ControlStatus {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 f.write_str(self.as_str())
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct ControlFinding {
183 pub control_id: ControlId,
184 pub status: ControlStatus,
185 pub rationale: String,
186 pub subjects: Vec<String>,
187 pub evidence_gaps: Vec<EvidenceGap>,
188}
189
190impl ControlFinding {
191 pub fn satisfied(
192 control_id: ControlId,
193 rationale: impl Into<String>,
194 subjects: Vec<String>,
195 ) -> Self {
196 Self {
197 control_id,
198 status: ControlStatus::Satisfied,
199 rationale: rationale.into(),
200 subjects,
201 evidence_gaps: Vec::new(),
202 }
203 }
204
205 pub fn violated(
206 control_id: ControlId,
207 rationale: impl Into<String>,
208 subjects: Vec<String>,
209 ) -> Self {
210 Self {
211 control_id,
212 status: ControlStatus::Violated,
213 rationale: rationale.into(),
214 subjects,
215 evidence_gaps: Vec::new(),
216 }
217 }
218
219 pub fn indeterminate(
220 control_id: ControlId,
221 rationale: impl Into<String>,
222 subjects: Vec<String>,
223 evidence_gaps: Vec<EvidenceGap>,
224 ) -> Self {
225 Self {
226 control_id,
227 status: ControlStatus::Indeterminate,
228 rationale: rationale.into(),
229 subjects,
230 evidence_gaps,
231 }
232 }
233
234 pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
235 Self {
236 control_id,
237 status: ControlStatus::NotApplicable,
238 rationale: rationale.into(),
239 subjects: Vec::new(),
240 evidence_gaps: Vec::new(),
241 }
242 }
243
244 pub fn extract_posture(
255 id: ControlId,
256 evidence: &EvidenceBundle,
257 ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
258 match &evidence.repository_posture {
259 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
260 EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
261 id,
262 "Repository posture evidence could not be collected",
263 vec![],
264 gaps.clone(),
265 )]),
266 EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
267 id,
268 "Repository posture not applicable",
269 )]),
270 }
271 }
272}
273
274pub trait Control: Send + Sync {
276 fn id(&self) -> ControlId;
278
279 fn description(&self) -> &'static str {
281 "Custom control"
282 }
283
284 fn tsc_criteria(&self) -> &'static [&'static str] {
287 builtin_tsc_mapping(self.id().as_str())
288 }
289
290 fn remediation_hint(&self) -> Option<&'static str> {
292 builtin_remediation_hint(self.id().as_str())
293 }
294
295 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
297}
298
299pub fn builtin_remediation_hint(id: &str) -> Option<&'static str> {
301 match id {
302 builtin::SOURCE_AUTHENTICITY => Some("Sign commits: git config commit.gpgsign true"),
303 builtin::REVIEW_INDEPENDENCE => {
304 Some("Ensure PRs are reviewed by someone other than the author")
305 }
306 builtin::BRANCH_HISTORY_INTEGRITY => {
307 Some("Use linear history (rebase/squash, avoid merge commits)")
308 }
309 builtin::BRANCH_PROTECTION_ENFORCEMENT => {
310 Some("Enable branch protection rules at Settings > Branches")
311 }
312 builtin::TWO_PARTY_REVIEW => {
313 Some("Require at least 2 reviewers in branch protection rules")
314 }
315 builtin::REQUIRED_STATUS_CHECKS => {
316 Some("Add required status checks in branch protection rules")
317 }
318 builtin::BUILD_PROVENANCE => {
319 Some("Generate SLSA provenance with slsa-framework/slsa-github-generator")
320 }
321 builtin::HOSTED_BUILD_PLATFORM => Some("Use GitHub-hosted runners instead of self-hosted"),
322 builtin::PROVENANCE_AUTHENTICITY => {
323 Some("Verify build provenance signatures with cosign/slsa-verifier")
324 }
325 builtin::BUILD_ISOLATION => Some("Ensure builds run in ephemeral, isolated environments"),
326 builtin::DEPENDENCY_SIGNATURE => {
327 Some("Use signed dependencies; verify with cosign or sigstore")
328 }
329 builtin::DEPENDENCY_PROVENANCE_CHECK => {
330 Some("Ensure dependencies publish SLSA provenance attestations")
331 }
332 builtin::DEPENDENCY_SIGNER_VERIFIED => {
333 Some("Verify dependency signers against a trusted list")
334 }
335 builtin::DEPENDENCY_COMPLETENESS => {
336 Some("Ensure all transitive dependencies have provenance")
337 }
338 builtin::CHANGE_REQUEST_SIZE => Some(
339 "Keep PRs small and focused; split large changes. Monorepo cross-package PRs may false-positive here -- use --exclude change-request-size",
340 ),
341 builtin::TEST_COVERAGE => Some(
342 "Add or update tests for changed source files. Dependency-only PRs may false-positive here -- use --exclude test-coverage",
343 ),
344 builtin::SCOPED_CHANGE => Some(
345 "Limit PR to a single logical change; split unrelated changes. In monorepos, features spanning multiple packages are expected -- use --exclude scoped-change",
346 ),
347 builtin::ISSUE_LINKAGE => Some(
348 "Reference an issue in the PR body: Fixes #123 or Closes #456. Bot PRs (Dependabot/Renovate) don't link issues -- use --exclude issue-linkage",
349 ),
350 builtin::DESCRIPTION_QUALITY => {
351 Some("Add a meaningful PR description explaining the change")
352 }
353 builtin::MERGE_COMMIT_POLICY => {
354 Some("Use squash or rebase merge strategy instead of merge commits")
355 }
356 builtin::CONVENTIONAL_TITLE => Some(
357 "Use Conventional Commits format: type(scope): description. Bot PRs use their own title format -- use --exclude conventional-title",
358 ),
359 builtin::STALE_REVIEW => Some("Re-request review if changes were pushed after approval"),
360 builtin::SECURITY_FILE_CHANGE => {
361 Some("Security-sensitive file changes require additional review")
362 }
363 builtin::RELEASE_TRACEABILITY => Some("Link release to merged PRs and resolved issues"),
364 builtin::CODEOWNERS_COVERAGE => Some("Add a CODEOWNERS file to define code ownership"),
365 builtin::SECRET_SCANNING => {
366 Some("Enable secret scanning at Settings > Code security and analysis")
367 }
368 builtin::VULNERABILITY_SCANNING => {
369 Some("Enable Dependabot alerts at Settings > Code security and analysis")
370 }
371 builtin::SECURITY_POLICY => {
372 Some("Add a SECURITY.md file with vulnerability reporting instructions")
373 }
374 builtin::CODE_SCANNING_ALERTS_RESOLVED => {
375 Some("Resolve open code scanning alerts at Security > Code scanning alerts")
376 }
377 builtin::RELEASE_ASSET_ATTESTATION => {
378 Some("Attest release assets with gh attestation or sigstore/cosign")
379 }
380 builtin::PRIVILEGED_WORKFLOW_DETECTION => {
381 Some("Avoid pull_request_target with checkout of PR code in workflows")
382 }
383 builtin::SECURITY_TEST_IN_CI => {
384 Some("Add CodeQL or Semgrep to GitHub Actions: github/codeql-action/analyze")
385 }
386 builtin::AGENT_SPEC_CONFORMANCE => Some(
387 "Define allowed_paths, forbidden_paths, and budget in agent spec to constrain agent scope",
388 ),
389 builtin::PRIVILEGED_OPERATION_AUDIT => Some(
390 "Review privileged git operations (force push, admin bypass, tag deletion) and restrict agent permissions",
391 ),
392 _ => None,
393 }
394}
395
396pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
398 match id {
399 builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
401 builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
402 builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
403 builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
404 builtin::ISSUE_LINKAGE => &["CC7.2"],
406 builtin::STALE_REVIEW => &["CC7.2"],
407 builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
408 builtin::RELEASE_TRACEABILITY => &["CC7.2"],
409 builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
410 builtin::VULNERABILITY_SCANNING => &["CC7.1"],
411 builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
412 builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
414 builtin::TWO_PARTY_REVIEW => &["CC8.1"],
415 builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
416 builtin::TEST_COVERAGE => &["CC8.1"],
417 builtin::SCOPED_CHANGE => &["CC8.1"],
418 builtin::DESCRIPTION_QUALITY => &["CC8.1"],
419 builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
420 builtin::CONVENTIONAL_TITLE => &["CC8.1"],
421 builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
422 builtin::BUILD_PROVENANCE => &["PI1.4"],
424 builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
425 builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
426 builtin::BUILD_ISOLATION => &["PI1.4"],
427 builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
429 builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
430 builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
431 builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
432 builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
434 builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
435 builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
436 builtin::AGENT_SPEC_CONFORMANCE => &["CC6.1", "CC8.1"],
438 builtin::PRIVILEGED_OPERATION_AUDIT => &["CC6.1", "CC7.2", "CC8.1"],
439 _ => &[],
440 }
441}
442
443pub fn evaluate_all(
445 controls: &[Box<dyn Control>],
446 evidence: &EvidenceBundle,
447) -> Vec<ControlFinding> {
448 let mut findings = Vec::new();
449 for control in controls {
450 findings.extend(control.evaluate(evidence));
451 }
452 findings
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 #[test]
460 fn control_id_display() {
461 let id = ControlId::new("review-independence");
462 assert_eq!(id.to_string(), "review-independence");
463 assert_eq!(id.as_str(), "review-independence");
464 }
465
466 #[test]
467 fn control_id_from_str() {
468 let id: ControlId = "source-authenticity".into();
469 assert_eq!(id.as_str(), "source-authenticity");
470 }
471
472 #[test]
473 fn all_builtins_have_remediation_hints() {
474 for id in builtin::ALL {
475 assert!(
476 builtin_remediation_hint(id).is_some(),
477 "missing remediation hint for built-in control: {id}"
478 );
479 }
480 }
481
482 #[test]
483 fn builtin_ids_are_unique() {
484 let mut seen = std::collections::HashSet::new();
485 for id in builtin::ALL {
486 assert!(seen.insert(id), "duplicate built-in ID: {id}");
487 }
488 }
489}