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 LICENSE_COMPLIANCE: &str = "license-compliance";
107 pub const SBOM_COMPLETENESS: &str = "sbom-completeness";
108
109 pub const HARNESS_GATE: &str = "harness-gate";
111 pub const COVERAGE_THRESHOLD: &str = "coverage-threshold";
112
113 pub const CONTAINER_SIGNATURE: &str = "container-signature";
115 pub const CONTAINER_PROVENANCE: &str = "container-provenance";
116
117 pub const BEHAVIORAL_REGRESSION: &str = "behavioral-regression";
119 pub const DEPLOYMENT_HEALTH: &str = "deployment-health";
120
121 pub const AGENT_SPEC_CONFORMANCE: &str = "agent-spec-conformance";
123 pub const PRIVILEGED_OPERATION_AUDIT: &str = "privileged-operation-audit";
124 pub const MCP_SCOPE_CHECK: &str = "mcp-scope-check";
125 pub const NETWORK_EGRESS_AUDIT: &str = "network-egress-audit";
126
127 pub const ALL: &[&str] = &[
129 SOURCE_AUTHENTICITY,
130 REVIEW_INDEPENDENCE,
131 BRANCH_HISTORY_INTEGRITY,
132 BRANCH_PROTECTION_ENFORCEMENT,
133 TWO_PARTY_REVIEW,
134 BUILD_PROVENANCE,
135 REQUIRED_STATUS_CHECKS,
136 HOSTED_BUILD_PLATFORM,
137 PROVENANCE_AUTHENTICITY,
138 BUILD_ISOLATION,
139 DEPENDENCY_SIGNATURE,
140 DEPENDENCY_PROVENANCE_CHECK,
141 DEPENDENCY_SIGNER_VERIFIED,
142 DEPENDENCY_COMPLETENESS,
143 CHANGE_REQUEST_SIZE,
144 TEST_COVERAGE,
145 SCOPED_CHANGE,
146 ISSUE_LINKAGE,
147 STALE_REVIEW,
148 DESCRIPTION_QUALITY,
149 MERGE_COMMIT_POLICY,
150 CONVENTIONAL_TITLE,
151 SECURITY_FILE_CHANGE,
152 RELEASE_TRACEABILITY,
153 CODEOWNERS_COVERAGE,
154 SECRET_SCANNING,
155 VULNERABILITY_SCANNING,
156 SECURITY_POLICY,
157 CODE_SCANNING_ALERTS_RESOLVED,
158 RELEASE_ASSET_ATTESTATION,
159 PRIVILEGED_WORKFLOW_DETECTION,
160 SECURITY_TEST_IN_CI,
161 LICENSE_COMPLIANCE,
162 SBOM_COMPLETENESS,
163 HARNESS_GATE,
164 COVERAGE_THRESHOLD,
165 CONTAINER_SIGNATURE,
166 CONTAINER_PROVENANCE,
167 BEHAVIORAL_REGRESSION,
168 DEPLOYMENT_HEALTH,
169 AGENT_SPEC_CONFORMANCE,
170 PRIVILEGED_OPERATION_AUDIT,
171 MCP_SCOPE_CHECK,
172 NETWORK_EGRESS_AUDIT,
173 ];
174
175 pub fn id(s: &str) -> ControlId {
177 ControlId::new(s)
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum ControlStatus {
185 Satisfied,
186 Violated,
187 Indeterminate,
188 NotApplicable,
189}
190
191impl ControlStatus {
192 pub fn as_str(&self) -> &'static str {
193 match self {
194 Self::Satisfied => "satisfied",
195 Self::Violated => "violated",
196 Self::Indeterminate => "indeterminate",
197 Self::NotApplicable => "not_applicable",
198 }
199 }
200}
201
202impl fmt::Display for ControlStatus {
203 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204 f.write_str(self.as_str())
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct ControlFinding {
211 pub control_id: ControlId,
212 pub status: ControlStatus,
213 pub rationale: String,
214 pub subjects: Vec<String>,
215 pub evidence_gaps: Vec<EvidenceGap>,
216}
217
218impl ControlFinding {
219 pub fn satisfied(
220 control_id: ControlId,
221 rationale: impl Into<String>,
222 subjects: Vec<String>,
223 ) -> Self {
224 Self {
225 control_id,
226 status: ControlStatus::Satisfied,
227 rationale: rationale.into(),
228 subjects,
229 evidence_gaps: Vec::new(),
230 }
231 }
232
233 pub fn violated(
234 control_id: ControlId,
235 rationale: impl Into<String>,
236 subjects: Vec<String>,
237 ) -> Self {
238 Self {
239 control_id,
240 status: ControlStatus::Violated,
241 rationale: rationale.into(),
242 subjects,
243 evidence_gaps: Vec::new(),
244 }
245 }
246
247 pub fn indeterminate(
248 control_id: ControlId,
249 rationale: impl Into<String>,
250 subjects: Vec<String>,
251 evidence_gaps: Vec<EvidenceGap>,
252 ) -> Self {
253 Self {
254 control_id,
255 status: ControlStatus::Indeterminate,
256 rationale: rationale.into(),
257 subjects,
258 evidence_gaps,
259 }
260 }
261
262 pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
263 Self {
264 control_id,
265 status: ControlStatus::NotApplicable,
266 rationale: rationale.into(),
267 subjects: Vec::new(),
268 evidence_gaps: Vec::new(),
269 }
270 }
271
272 pub fn extract_posture(
283 id: ControlId,
284 evidence: &EvidenceBundle,
285 ) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
286 match &evidence.repository_posture {
287 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
288 EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
289 id,
290 "Repository posture evidence could not be collected",
291 vec![],
292 gaps.clone(),
293 )]),
294 EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
295 id,
296 "Repository posture not applicable",
297 )]),
298 }
299 }
300}
301
302pub trait Control: Send + Sync {
304 fn id(&self) -> ControlId;
306
307 fn description(&self) -> &'static str {
309 "Custom control"
310 }
311
312 fn tsc_criteria(&self) -> &'static [&'static str] {
315 builtin_tsc_mapping(self.id().as_str())
316 }
317
318 fn remediation_hint(&self) -> Option<&'static str> {
320 builtin_remediation_hint(self.id().as_str())
321 }
322
323 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
325}
326
327pub fn builtin_remediation_hint(id: &str) -> Option<&'static str> {
329 match id {
330 builtin::SOURCE_AUTHENTICITY => Some("Sign commits: git config commit.gpgsign true"),
331 builtin::REVIEW_INDEPENDENCE => {
332 Some("Ensure PRs are reviewed by someone other than the author")
333 }
334 builtin::BRANCH_HISTORY_INTEGRITY => {
335 Some("Use linear history (rebase/squash, avoid merge commits)")
336 }
337 builtin::BRANCH_PROTECTION_ENFORCEMENT => {
338 Some("Enable branch protection rules at Settings > Branches")
339 }
340 builtin::TWO_PARTY_REVIEW => {
341 Some("Require at least 2 reviewers in branch protection rules")
342 }
343 builtin::REQUIRED_STATUS_CHECKS => {
344 Some("Add required status checks in branch protection rules")
345 }
346 builtin::BUILD_PROVENANCE => {
347 Some("Generate SLSA provenance with slsa-framework/slsa-github-generator")
348 }
349 builtin::HOSTED_BUILD_PLATFORM => Some("Use GitHub-hosted runners instead of self-hosted"),
350 builtin::PROVENANCE_AUTHENTICITY => {
351 Some("Verify build provenance signatures with cosign/slsa-verifier")
352 }
353 builtin::BUILD_ISOLATION => Some("Ensure builds run in ephemeral, isolated environments"),
354 builtin::DEPENDENCY_SIGNATURE => {
355 Some("Use signed dependencies; verify with cosign or sigstore")
356 }
357 builtin::DEPENDENCY_PROVENANCE_CHECK => {
358 Some("Ensure dependencies publish SLSA provenance attestations")
359 }
360 builtin::DEPENDENCY_SIGNER_VERIFIED => {
361 Some("Verify dependency signers against a trusted list")
362 }
363 builtin::DEPENDENCY_COMPLETENESS => {
364 Some("Ensure all transitive dependencies have provenance")
365 }
366 builtin::CHANGE_REQUEST_SIZE => Some(
367 "Keep PRs small and focused; split large changes. Monorepo cross-package PRs may false-positive here -- use --exclude change-request-size",
368 ),
369 builtin::TEST_COVERAGE => Some(
370 "Add or update tests for changed source files. Dependency-only PRs may false-positive here -- use --exclude test-coverage",
371 ),
372 builtin::SCOPED_CHANGE => Some(
373 "Limit PR to a single logical change; split unrelated changes. In monorepos, features spanning multiple packages are expected -- use --exclude scoped-change",
374 ),
375 builtin::ISSUE_LINKAGE => Some(
376 "Reference an issue in the PR body: Fixes #123 or Closes #456. Bot PRs (Dependabot/Renovate) don't link issues -- use --exclude issue-linkage",
377 ),
378 builtin::DESCRIPTION_QUALITY => {
379 Some("Add a meaningful PR description explaining the change")
380 }
381 builtin::MERGE_COMMIT_POLICY => {
382 Some("Use squash or rebase merge strategy instead of merge commits")
383 }
384 builtin::CONVENTIONAL_TITLE => Some(
385 "Use Conventional Commits format: type(scope): description. Bot PRs use their own title format -- use --exclude conventional-title",
386 ),
387 builtin::STALE_REVIEW => Some("Re-request review if changes were pushed after approval"),
388 builtin::SECURITY_FILE_CHANGE => {
389 Some("Security-sensitive file changes require additional review")
390 }
391 builtin::RELEASE_TRACEABILITY => Some("Link release to merged PRs and resolved issues"),
392 builtin::CODEOWNERS_COVERAGE => Some("Add a CODEOWNERS file to define code ownership"),
393 builtin::SECRET_SCANNING => {
394 Some("Enable secret scanning at Settings > Code security and analysis")
395 }
396 builtin::VULNERABILITY_SCANNING => {
397 Some("Enable Dependabot alerts at Settings > Code security and analysis")
398 }
399 builtin::SECURITY_POLICY => {
400 Some("Add a SECURITY.md file with vulnerability reporting instructions")
401 }
402 builtin::CODE_SCANNING_ALERTS_RESOLVED => {
403 Some("Resolve open code scanning alerts at Security > Code scanning alerts")
404 }
405 builtin::RELEASE_ASSET_ATTESTATION => {
406 Some("Attest release assets with gh attestation or sigstore/cosign")
407 }
408 builtin::PRIVILEGED_WORKFLOW_DETECTION => {
409 Some("Avoid pull_request_target with checkout of PR code in workflows")
410 }
411 builtin::SECURITY_TEST_IN_CI => {
412 Some("Add CodeQL or Semgrep to GitHub Actions: github/codeql-action/analyze")
413 }
414 builtin::LICENSE_COMPLIANCE => Some(
415 "Review copyleft dependencies (GPL, AGPL, SSPL) and replace with permissively-licensed alternatives or obtain legal approval",
416 ),
417 builtin::SBOM_COMPLETENESS => {
418 Some("Generate SBOM with syft, cyclonedx-cli, or cargo-sbom and attach to releases")
419 }
420 builtin::HARNESS_GATE => {
421 Some("Fix failing CI checks before merging. Run tests locally: cargo test / npm test")
422 }
423 builtin::COVERAGE_THRESHOLD => {
424 Some("Increase test coverage. Current coverage is below the minimum threshold")
425 }
426 builtin::CONTAINER_SIGNATURE => {
427 Some("Sign container images with cosign: cosign sign --yes ghcr.io/owner/repo:tag")
428 }
429 builtin::CONTAINER_PROVENANCE => Some(
430 "Generate SLSA provenance for container images using slsa-framework/slsa-github-generator or ko build --provenance",
431 ),
432 builtin::BEHAVIORAL_REGRESSION => Some(
433 "Investigate metric regressions post-deploy. Consider rolling back if latency increased >10% or error rate increased >5%",
434 ),
435 builtin::DEPLOYMENT_HEALTH => Some(
436 "Service health degraded post-deployment. Error rate exceeds 5% or availability below 99%. Consider immediate rollback",
437 ),
438 builtin::AGENT_SPEC_CONFORMANCE => Some(
439 "Define allowed_paths, forbidden_paths, and budget in agent spec to constrain agent scope",
440 ),
441 builtin::PRIVILEGED_OPERATION_AUDIT => Some(
442 "Review privileged git operations (force push, admin bypass, tag deletion) and restrict agent permissions",
443 ),
444 builtin::MCP_SCOPE_CHECK => Some(
445 "Restrict MCP tool access in agent spec. Add allowed_tools entries like 'mcp:github/*' or remove forbidden servers",
446 ),
447 builtin::NETWORK_EGRESS_AUDIT => Some(
448 "Review agent network access. Restrict outbound connections in agent spec or network policy",
449 ),
450 _ => None,
451 }
452}
453
454pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
456 match id {
457 builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
459 builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
460 builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
461 builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
462 builtin::ISSUE_LINKAGE => &["CC7.2"],
464 builtin::STALE_REVIEW => &["CC7.2"],
465 builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
466 builtin::RELEASE_TRACEABILITY => &["CC7.2"],
467 builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
468 builtin::VULNERABILITY_SCANNING => &["CC7.1"],
469 builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
470 builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
472 builtin::TWO_PARTY_REVIEW => &["CC8.1"],
473 builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
474 builtin::TEST_COVERAGE => &["CC8.1"],
475 builtin::SCOPED_CHANGE => &["CC8.1"],
476 builtin::DESCRIPTION_QUALITY => &["CC8.1"],
477 builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
478 builtin::CONVENTIONAL_TITLE => &["CC8.1"],
479 builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
480 builtin::BUILD_PROVENANCE => &["PI1.4"],
482 builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
483 builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
484 builtin::BUILD_ISOLATION => &["PI1.4"],
485 builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
487 builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
488 builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
489 builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
490 builtin::CODE_SCANNING_ALERTS_RESOLVED => &["CC7.1"],
492 builtin::RELEASE_ASSET_ATTESTATION => &["PI1.4"],
493 builtin::PRIVILEGED_WORKFLOW_DETECTION => &["CC6.1", "CC8.1"],
494 builtin::LICENSE_COMPLIANCE => &["CC7.1"],
496 builtin::SBOM_COMPLETENESS => &["CC7.1", "PI1.4"],
497 builtin::HARNESS_GATE => &["CC7.1", "CC8.1"],
499 builtin::COVERAGE_THRESHOLD => &["CC8.1"],
500 builtin::CONTAINER_SIGNATURE => &["PI1.4"],
502 builtin::CONTAINER_PROVENANCE => &["PI1.4"],
503 builtin::BEHAVIORAL_REGRESSION => &["CC7.1"],
505 builtin::DEPLOYMENT_HEALTH => &["CC7.1", "CC7.2"],
506 builtin::AGENT_SPEC_CONFORMANCE => &["CC6.1", "CC8.1"],
508 builtin::PRIVILEGED_OPERATION_AUDIT => &["CC6.1", "CC7.2", "CC8.1"],
509 builtin::MCP_SCOPE_CHECK => &["CC6.1", "CC8.1"],
510 builtin::NETWORK_EGRESS_AUDIT => &["CC6.1", "CC6.6"],
511 _ => &[],
512 }
513}
514
515pub fn evaluate_all(
517 controls: &[Box<dyn Control>],
518 evidence: &EvidenceBundle,
519) -> Vec<ControlFinding> {
520 let mut findings = Vec::new();
521 for control in controls {
522 findings.extend(control.evaluate(evidence));
523 }
524 findings
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn control_id_display() {
533 let id = ControlId::new("review-independence");
534 assert_eq!(id.to_string(), "review-independence");
535 assert_eq!(id.as_str(), "review-independence");
536 }
537
538 #[test]
539 fn control_id_from_str() {
540 let id: ControlId = "source-authenticity".into();
541 assert_eq!(id.as_str(), "source-authenticity");
542 }
543
544 #[test]
545 fn all_builtins_have_remediation_hints() {
546 for id in builtin::ALL {
547 assert!(
548 builtin_remediation_hint(id).is_some(),
549 "missing remediation hint for built-in control: {id}"
550 );
551 }
552 }
553
554 #[test]
555 fn builtin_ids_are_unique() {
556 let mut seen = std::collections::HashSet::new();
557 for id in builtin::ALL {
558 assert!(seen.insert(id), "duplicate built-in ID: {id}");
559 }
560 }
561}