Skip to main content

libverify_core/controls/
security_file_change.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4/// Path patterns that indicate security-sensitive files.
5///
6/// Changes to these files alter CI pipelines, access control, dependency
7/// resolution, or authentication configuration — all of which require
8/// heightened review scrutiny.
9const SENSITIVE_PATTERNS: &[&str] = &[
10    // CI / CD configuration
11    ".github/workflows/",
12    ".github/actions/",
13    ".gitlab-ci.yml",
14    "Jenkinsfile",
15    ".circleci/",
16    ".travis.yml",
17    // Access control
18    "CODEOWNERS",
19    ".github/CODEOWNERS",
20    // Dependency management (supply chain)
21    "Cargo.lock",
22    "package-lock.json",
23    "yarn.lock",
24    "pnpm-lock.yaml",
25    "Gemfile.lock",
26    "poetry.lock",
27    "go.sum",
28    // Security configuration
29    ".gitattributes",
30    ".gitmodules",
31    // Docker / container
32    "Dockerfile",
33    "docker-compose",
34    // Infrastructure as Code
35    "terraform/",
36    ".terraform",
37    "pulumi/",
38];
39
40/// Detects changes to security-sensitive files that require heightened review.
41///
42/// Maps to SOC2 CC7.2: monitoring for anomalies in change governance.
43/// Changes to CI configs, access control, lock files, and infrastructure
44/// definitions have outsized blast radius and should be flagged for scrutiny.
45pub struct SecurityFileChangeControl;
46
47impl Control for SecurityFileChangeControl {
48    fn id(&self) -> ControlId {
49        builtin::id(builtin::SECURITY_FILE_CHANGE)
50    }
51
52    fn description(&self) -> &'static str {
53        "Changes to security-sensitive files require heightened scrutiny"
54    }
55
56    fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
57        if evidence.change_requests.is_empty() {
58            return vec![ControlFinding::not_applicable(
59                self.id(),
60                "No change requests found",
61            )];
62        }
63
64        evidence
65            .change_requests
66            .iter()
67            .map(|cr| evaluate_change(self.id(), cr))
68            .collect()
69    }
70}
71
72fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
73    let cr_subject = cr.id.to_string();
74
75    let assets = match &cr.changed_assets {
76        EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
77        EvidenceState::Missing { gaps } => {
78            return ControlFinding::indeterminate(
79                id,
80                format!("{cr_subject}: changed asset evidence could not be collected"),
81                vec![cr_subject],
82                gaps.clone(),
83            );
84        }
85        EvidenceState::NotApplicable => {
86            return ControlFinding::not_applicable(id, "Changed assets not applicable");
87        }
88    };
89
90    let sensitive_files: Vec<&str> = assets
91        .iter()
92        .filter(|a| is_sensitive_path(&a.path))
93        .map(|a| a.path.as_str())
94        .collect();
95
96    if sensitive_files.is_empty() {
97        ControlFinding::satisfied(
98            id,
99            format!("{cr_subject}: no security-sensitive files changed"),
100            vec![cr_subject],
101        )
102    } else {
103        ControlFinding::violated(
104            id,
105            format!(
106                "{cr_subject}: {} security-sensitive file(s) changed: {}",
107                sensitive_files.len(),
108                sensitive_files.join(", ")
109            ),
110            sensitive_files.into_iter().map(String::from).collect(),
111        )
112    }
113}
114
115/// Returns true if the path matches a security-sensitive pattern.
116fn is_sensitive_path(path: &str) -> bool {
117    let lower = path.to_lowercase();
118    SENSITIVE_PATTERNS
119        .iter()
120        .any(|pattern| lower.contains(&pattern.to_lowercase()))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::control::ControlStatus;
127    use crate::evidence::{ChangeRequestId, ChangedAsset, EvidenceGap};
128
129    fn asset(path: &str) -> ChangedAsset {
130        ChangedAsset {
131            path: path.to_string(),
132            diff_available: true,
133            additions: 1,
134            deletions: 0,
135            status: "modified".to_string(),
136            diff: None,
137        }
138    }
139
140    fn make_change(assets: EvidenceState<Vec<ChangedAsset>>) -> GovernedChange {
141        GovernedChange {
142            id: ChangeRequestId::new("test", "owner/repo#1"),
143            title: "test".to_string(),
144            summary: None,
145            submitted_by: None,
146            changed_assets: assets,
147            approval_decisions: EvidenceState::not_applicable(),
148            source_revisions: EvidenceState::not_applicable(),
149            work_item_refs: EvidenceState::not_applicable(),
150        }
151    }
152
153    fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
154        EvidenceBundle {
155            change_requests: changes,
156            ..Default::default()
157        }
158    }
159
160    #[test]
161    fn not_applicable_when_no_changes() {
162        let findings = SecurityFileChangeControl.evaluate(&EvidenceBundle::default());
163        assert_eq!(findings[0].status, ControlStatus::NotApplicable);
164    }
165
166    #[test]
167    fn satisfied_when_no_sensitive_files() {
168        let cr = make_change(EvidenceState::complete(vec![
169            asset("src/main.rs"),
170            asset("src/lib.rs"),
171        ]));
172        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
173        assert_eq!(findings[0].status, ControlStatus::Satisfied);
174    }
175
176    #[test]
177    fn violated_when_workflow_changed() {
178        let cr = make_change(EvidenceState::complete(vec![
179            asset("src/main.rs"),
180            asset(".github/workflows/ci.yml"),
181        ]));
182        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
183        assert_eq!(findings[0].status, ControlStatus::Violated);
184        assert!(findings[0].rationale.contains("ci.yml"));
185    }
186
187    #[test]
188    fn violated_when_codeowners_changed() {
189        let cr = make_change(EvidenceState::complete(vec![asset("CODEOWNERS")]));
190        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
191        assert_eq!(findings[0].status, ControlStatus::Violated);
192    }
193
194    #[test]
195    fn violated_when_lockfile_changed() {
196        let cr = make_change(EvidenceState::complete(vec![asset("Cargo.lock")]));
197        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
198        assert_eq!(findings[0].status, ControlStatus::Violated);
199    }
200
201    #[test]
202    fn violated_when_dockerfile_changed() {
203        let cr = make_change(EvidenceState::complete(vec![asset("Dockerfile")]));
204        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
205        assert_eq!(findings[0].status, ControlStatus::Violated);
206    }
207
208    #[test]
209    fn indeterminate_when_assets_missing() {
210        let cr = make_change(EvidenceState::missing(vec![
211            EvidenceGap::CollectionFailed {
212                source: "github".to_string(),
213                subject: "files".to_string(),
214                detail: "API error".to_string(),
215            },
216        ]));
217        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
218        assert_eq!(findings[0].status, ControlStatus::Indeterminate);
219    }
220
221    #[test]
222    fn multiple_sensitive_files_all_reported() {
223        let cr = make_change(EvidenceState::complete(vec![
224            asset(".github/workflows/release.yml"),
225            asset("CODEOWNERS"),
226            asset("Cargo.lock"),
227        ]));
228        let findings = SecurityFileChangeControl.evaluate(&bundle(vec![cr]));
229        assert_eq!(findings[0].status, ControlStatus::Violated);
230        assert_eq!(findings[0].subjects.len(), 3);
231    }
232}