use std::fmt;
use serde::{Deserialize, Serialize};
use crate::evidence::{EvidenceBundle, EvidenceGap, EvidenceState, RepositoryPosture};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ControlId(String);
impl ControlId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ControlId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for ControlId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for ControlId {
fn from(s: String) -> Self {
Self(s)
}
}
pub mod builtin {
use super::ControlId;
pub const SOURCE_AUTHENTICITY: &str = "source-authenticity";
pub const REVIEW_INDEPENDENCE: &str = "review-independence";
pub const BRANCH_HISTORY_INTEGRITY: &str = "branch-history-integrity";
pub const BRANCH_PROTECTION_ENFORCEMENT: &str = "branch-protection-enforcement";
pub const TWO_PARTY_REVIEW: &str = "two-party-review";
pub const BUILD_PROVENANCE: &str = "build-provenance";
pub const REQUIRED_STATUS_CHECKS: &str = "required-status-checks";
pub const HOSTED_BUILD_PLATFORM: &str = "hosted-build-platform";
pub const PROVENANCE_AUTHENTICITY: &str = "provenance-authenticity";
pub const BUILD_ISOLATION: &str = "build-isolation";
pub const DEPENDENCY_SIGNATURE: &str = "dependency-signature";
pub const DEPENDENCY_PROVENANCE_CHECK: &str = "dependency-provenance";
pub const DEPENDENCY_SIGNER_VERIFIED: &str = "dependency-signer-verified";
pub const DEPENDENCY_COMPLETENESS: &str = "dependency-completeness";
pub const CHANGE_REQUEST_SIZE: &str = "change-request-size";
pub const TEST_COVERAGE: &str = "test-coverage";
pub const SCOPED_CHANGE: &str = "scoped-change";
pub const ISSUE_LINKAGE: &str = "issue-linkage";
pub const STALE_REVIEW: &str = "stale-review";
pub const DESCRIPTION_QUALITY: &str = "description-quality";
pub const MERGE_COMMIT_POLICY: &str = "merge-commit-policy";
pub const CONVENTIONAL_TITLE: &str = "conventional-title";
pub const SECURITY_FILE_CHANGE: &str = "security-file-change";
pub const RELEASE_TRACEABILITY: &str = "release-traceability";
pub const CODEOWNERS_COVERAGE: &str = "codeowners-coverage";
pub const SECRET_SCANNING: &str = "secret-scanning";
pub const VULNERABILITY_SCANNING: &str = "vulnerability-scanning";
pub const SECURITY_POLICY: &str = "security-policy";
pub const ALL: &[&str] = &[
SOURCE_AUTHENTICITY,
REVIEW_INDEPENDENCE,
BRANCH_HISTORY_INTEGRITY,
BRANCH_PROTECTION_ENFORCEMENT,
TWO_PARTY_REVIEW,
BUILD_PROVENANCE,
REQUIRED_STATUS_CHECKS,
HOSTED_BUILD_PLATFORM,
PROVENANCE_AUTHENTICITY,
BUILD_ISOLATION,
DEPENDENCY_SIGNATURE,
DEPENDENCY_PROVENANCE_CHECK,
DEPENDENCY_SIGNER_VERIFIED,
DEPENDENCY_COMPLETENESS,
CHANGE_REQUEST_SIZE,
TEST_COVERAGE,
SCOPED_CHANGE,
ISSUE_LINKAGE,
STALE_REVIEW,
DESCRIPTION_QUALITY,
MERGE_COMMIT_POLICY,
CONVENTIONAL_TITLE,
SECURITY_FILE_CHANGE,
RELEASE_TRACEABILITY,
CODEOWNERS_COVERAGE,
SECRET_SCANNING,
VULNERABILITY_SCANNING,
SECURITY_POLICY,
];
pub fn id(s: &str) -> ControlId {
ControlId::new(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlStatus {
Satisfied,
Violated,
Indeterminate,
NotApplicable,
}
impl ControlStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Satisfied => "satisfied",
Self::Violated => "violated",
Self::Indeterminate => "indeterminate",
Self::NotApplicable => "not_applicable",
}
}
}
impl fmt::Display for ControlStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlFinding {
pub control_id: ControlId,
pub status: ControlStatus,
pub rationale: String,
pub subjects: Vec<String>,
pub evidence_gaps: Vec<EvidenceGap>,
}
impl ControlFinding {
pub fn satisfied(
control_id: ControlId,
rationale: impl Into<String>,
subjects: Vec<String>,
) -> Self {
Self {
control_id,
status: ControlStatus::Satisfied,
rationale: rationale.into(),
subjects,
evidence_gaps: Vec::new(),
}
}
pub fn violated(
control_id: ControlId,
rationale: impl Into<String>,
subjects: Vec<String>,
) -> Self {
Self {
control_id,
status: ControlStatus::Violated,
rationale: rationale.into(),
subjects,
evidence_gaps: Vec::new(),
}
}
pub fn indeterminate(
control_id: ControlId,
rationale: impl Into<String>,
subjects: Vec<String>,
evidence_gaps: Vec<EvidenceGap>,
) -> Self {
Self {
control_id,
status: ControlStatus::Indeterminate,
rationale: rationale.into(),
subjects,
evidence_gaps,
}
}
pub fn not_applicable(control_id: ControlId, rationale: impl Into<String>) -> Self {
Self {
control_id,
status: ControlStatus::NotApplicable,
rationale: rationale.into(),
subjects: Vec::new(),
evidence_gaps: Vec::new(),
}
}
pub fn extract_posture(
id: ControlId,
evidence: &EvidenceBundle,
) -> Result<&RepositoryPosture, Vec<ControlFinding>> {
match &evidence.repository_posture {
EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => Ok(value),
EvidenceState::Missing { gaps } => Err(vec![ControlFinding::indeterminate(
id,
"Repository posture evidence could not be collected",
vec![],
gaps.clone(),
)]),
EvidenceState::NotApplicable => Err(vec![ControlFinding::not_applicable(
id,
"Repository posture not applicable",
)]),
}
}
}
pub trait Control: Send + Sync {
fn id(&self) -> ControlId;
fn description(&self) -> &'static str {
"Custom control"
}
fn tsc_criteria(&self) -> &'static [&'static str] {
builtin_tsc_mapping(self.id().as_str())
}
fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding>;
}
pub fn builtin_tsc_mapping(id: &str) -> &'static [&'static str] {
match id {
builtin::SOURCE_AUTHENTICITY => &["CC6.1"],
builtin::BRANCH_PROTECTION_ENFORCEMENT => &["CC6.1", "CC8.1"],
builtin::CODEOWNERS_COVERAGE => &["CC6.1"],
builtin::SECRET_SCANNING => &["CC6.1", "CC6.6"],
builtin::ISSUE_LINKAGE => &["CC7.2"],
builtin::STALE_REVIEW => &["CC7.2"],
builtin::SECURITY_FILE_CHANGE => &["CC7.2"],
builtin::RELEASE_TRACEABILITY => &["CC7.2"],
builtin::REQUIRED_STATUS_CHECKS => &["CC7.1"],
builtin::VULNERABILITY_SCANNING => &["CC7.1"],
builtin::SECURITY_POLICY => &["CC7.3", "CC7.4"],
builtin::REVIEW_INDEPENDENCE => &["CC8.1"],
builtin::TWO_PARTY_REVIEW => &["CC8.1"],
builtin::CHANGE_REQUEST_SIZE => &["CC8.1"],
builtin::TEST_COVERAGE => &["CC8.1"],
builtin::SCOPED_CHANGE => &["CC8.1"],
builtin::DESCRIPTION_QUALITY => &["CC8.1"],
builtin::MERGE_COMMIT_POLICY => &["CC8.1"],
builtin::CONVENTIONAL_TITLE => &["CC8.1"],
builtin::BRANCH_HISTORY_INTEGRITY => &["CC8.1"],
builtin::BUILD_PROVENANCE => &["PI1.4"],
builtin::HOSTED_BUILD_PLATFORM => &["PI1.4"],
builtin::PROVENANCE_AUTHENTICITY => &["PI1.4"],
builtin::BUILD_ISOLATION => &["PI1.4"],
builtin::DEPENDENCY_SIGNATURE => &["CC7.1", "PI1.4"],
builtin::DEPENDENCY_PROVENANCE_CHECK => &["CC7.1", "PI1.4"],
builtin::DEPENDENCY_SIGNER_VERIFIED => &["CC7.1", "PI1.4"],
builtin::DEPENDENCY_COMPLETENESS => &["CC7.1", "PI1.4"],
_ => &[],
}
}
pub fn evaluate_all(
controls: &[Box<dyn Control>],
evidence: &EvidenceBundle,
) -> Vec<ControlFinding> {
let mut findings = Vec::new();
for control in controls {
findings.extend(control.evaluate(evidence));
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_id_display() {
let id = ControlId::new("review-independence");
assert_eq!(id.to_string(), "review-independence");
assert_eq!(id.as_str(), "review-independence");
}
#[test]
fn control_id_from_str() {
let id: ControlId = "source-authenticity".into();
assert_eq!(id.as_str(), "source-authenticity");
}
#[test]
fn builtin_ids_are_unique() {
let mut seen = std::collections::HashSet::new();
for id in builtin::ALL {
assert!(seen.insert(id), "duplicate built-in ID: {id}");
}
}
}