1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4const SENSITIVE_PATTERNS: &[&str] = &[
10 ".github/workflows/",
12 ".github/actions/",
13 ".gitlab-ci.yml",
14 "Jenkinsfile",
15 ".circleci/",
16 ".travis.yml",
17 "CODEOWNERS",
19 ".github/CODEOWNERS",
20 "Cargo.lock",
22 "package-lock.json",
23 "yarn.lock",
24 "pnpm-lock.yaml",
25 "Gemfile.lock",
26 "poetry.lock",
27 "go.sum",
28 ".gitattributes",
30 ".gitmodules",
31 "Dockerfile",
33 "docker-compose",
34 "terraform/",
36 ".terraform",
37 "pulumi/",
38];
39
40pub 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
115fn 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}