1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4pub const NOTABLE_COMMAND_PATTERNS: &[&str] = &[
7 "rm -rf",
9 "rm -r",
10 "rm -fr",
11 "shred ",
12 "find / -delete",
13 "find . -delete",
14 "drop table",
16 "drop database",
17 "drop schema",
18 "truncate table",
19 "delete from",
20 "git push --force",
22 "git push -f",
23 "git reset --hard",
24 "git push origin :",
25 "kubectl delete",
27 "kubectl drain",
28 "helm uninstall",
29 "helm delete",
30 "docker rm",
31 "docker system prune",
32 "docker-compose down -v",
33 "terraform destroy",
35 "pulumi destroy",
36 "aws s3 rm",
38 "aws s3 rb",
39 "aws ec2 terminate",
40 "aws rds delete",
41 "aws lambda delete",
42 "gsutil rm",
43 "gcloud compute instances delete",
44 "az vm delete",
45 "az storage blob delete",
46 "chmod 000",
48 "chmod -r 000",
49 "iptables -f",
50 "systemctl stop",
51 "kill -9",
52 "format c:",
53];
54
55pub struct PrivilegedOperationAuditControl;
62
63impl Control for PrivilegedOperationAuditControl {
64 fn id(&self) -> ControlId {
65 builtin::id(builtin::PRIVILEGED_OPERATION_AUDIT)
66 }
67
68 fn description(&self) -> &'static str {
69 "Privileged operations (force push, notable commands, admin bypass) must be audited"
70 }
71
72 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
73 let id = self.id();
74 let mut subjects: Vec<String> = Vec::new();
75
76 let git_gaps = match &evidence.privileged_git_events {
78 EvidenceState::Complete { value } => {
79 for e in value {
80 let target = e
81 .branch
82 .as_deref()
83 .or(e.tag.as_deref())
84 .unwrap_or("unknown");
85 subjects.push(format!(
86 "{}: {} on {} by {}",
87 e.action.as_str(),
88 e.detail.as_deref().unwrap_or(""),
89 target,
90 e.actor
91 ));
92 }
93 false
94 }
95 EvidenceState::Partial { value, .. } => {
96 for e in value {
97 let target = e
98 .branch
99 .as_deref()
100 .or(e.tag.as_deref())
101 .unwrap_or("unknown");
102 subjects.push(format!(
103 "{}: {} on {} by {}",
104 e.action.as_str(),
105 e.detail.as_deref().unwrap_or(""),
106 target,
107 e.actor
108 ));
109 }
110 true
111 }
112 EvidenceState::Missing { .. } | EvidenceState::NotApplicable => false,
113 };
114
115 let action_gaps = match &evidence.agent_action_log {
117 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
118 let custom_patterns: Vec<String> = match &evidence.agent_spec {
119 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
120 value
121 .custom_destructive_patterns
122 .iter()
123 .map(|p| p.to_lowercase())
124 .collect()
125 }
126 _ => vec![],
127 };
128
129 for action in &value.actions {
130 let lower = action.command.to_lowercase();
131 let matched = NOTABLE_COMMAND_PATTERNS.iter().any(|p| lower.contains(p))
132 || custom_patterns.iter().any(|p| lower.contains(p.as_str()));
133 if matched {
134 subjects.push(format!("command: {}", action.command));
135 }
136 }
137 matches!(&evidence.agent_action_log, EvidenceState::Partial { .. })
138 }
139 EvidenceState::Missing { .. } | EvidenceState::NotApplicable => false,
140 };
141
142 let git_missing = matches!(
144 evidence.privileged_git_events,
145 EvidenceState::Missing { .. }
146 );
147 let log_missing = matches!(evidence.agent_action_log, EvidenceState::Missing { .. });
148 let git_na = matches!(evidence.privileged_git_events, EvidenceState::NotApplicable);
149 let log_na = matches!(evidence.agent_action_log, EvidenceState::NotApplicable);
150
151 if git_na && log_na {
152 return vec![ControlFinding::not_applicable(
153 id,
154 "No privileged operation evidence applicable",
155 )];
156 }
157
158 if git_missing && log_missing {
159 let mut gaps = vec![];
160 if let EvidenceState::Missing { gaps: g } = &evidence.privileged_git_events {
161 gaps.extend(g.clone());
162 }
163 if let EvidenceState::Missing { gaps: g } = &evidence.agent_action_log {
164 gaps.extend(g.clone());
165 }
166 return vec![ControlFinding::indeterminate(
167 id,
168 "Privileged operation evidence is missing",
169 vec![],
170 gaps,
171 )];
172 }
173
174 if subjects.is_empty() {
176 let mut rationale = "No privileged operations detected".to_string();
177 if git_gaps || action_gaps {
178 rationale
179 .push_str(" (partial evidence — some operations may not have been captured)");
180 }
181 vec![ControlFinding::satisfied(id, rationale, vec![])]
182 } else {
183 let count = subjects.len();
184 vec![ControlFinding::violated(
185 id,
186 format!("{count} privileged operation(s) detected"),
187 subjects,
188 )]
189 }
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::control::ControlStatus;
197 use crate::evidence::*;
198
199 fn git_event(
200 actor: &str,
201 action: PrivilegedAction,
202 branch: Option<&str>,
203 tag: Option<&str>,
204 ) -> PrivilegedGitEvent {
205 PrivilegedGitEvent {
206 actor: actor.to_string(),
207 action,
208 branch: branch.map(String::from),
209 tag: tag.map(String::from),
210 timestamp: None,
211 commit_sha: None,
212 detail: Some("test event".to_string()),
213 }
214 }
215
216 fn action(command: &str) -> AgentAction {
217 AgentAction {
218 tool: "shell".to_string(),
219 command: command.to_string(),
220 timestamp: None,
221 }
222 }
223
224 fn log_with(actions: Vec<AgentAction>) -> AgentActionLog {
225 AgentActionLog {
226 agent_id: "test-agent".to_string(),
227 session_id: "session-1".to_string(),
228 actions,
229 }
230 }
231
232 #[test]
235 fn no_events_no_actions_satisfied() {
236 let b = EvidenceBundle {
237 privileged_git_events: EvidenceState::complete(vec![]),
238 agent_action_log: EvidenceState::complete(log_with(vec![])),
239 ..Default::default()
240 };
241 let findings = PrivilegedOperationAuditControl.evaluate(&b);
242 assert_eq!(findings[0].status, ControlStatus::Satisfied);
243 }
244
245 #[test]
246 fn force_push_from_git_events() {
247 let b = EvidenceBundle {
248 privileged_git_events: EvidenceState::complete(vec![git_event(
249 "bot",
250 PrivilegedAction::ForcePush,
251 Some("main"),
252 None,
253 )]),
254 ..Default::default()
255 };
256 let findings = PrivilegedOperationAuditControl.evaluate(&b);
257 assert_eq!(findings[0].status, ControlStatus::Violated);
258 assert!(findings[0].subjects[0].contains("force-push"));
259 }
260
261 #[test]
262 fn admin_bypass_from_git_events() {
263 let b = EvidenceBundle {
264 privileged_git_events: EvidenceState::complete(vec![git_event(
265 "admin",
266 PrivilegedAction::AdminBypassProtection,
267 Some("main"),
268 None,
269 )]),
270 ..Default::default()
271 };
272 let findings = PrivilegedOperationAuditControl.evaluate(&b);
273 assert_eq!(findings[0].status, ControlStatus::Violated);
274 }
275
276 #[test]
277 fn tag_deletion_from_git_events() {
278 let b = EvidenceBundle {
279 privileged_git_events: EvidenceState::complete(vec![git_event(
280 "bot",
281 PrivilegedAction::TagDeletion,
282 None,
283 Some("v1.0.0"),
284 )]),
285 ..Default::default()
286 };
287 let findings = PrivilegedOperationAuditControl.evaluate(&b);
288 assert_eq!(findings[0].status, ControlStatus::Violated);
289 assert!(findings[0].subjects[0].contains("v1.0.0"));
290 }
291
292 #[test]
295 fn rm_rf_from_action_log() {
296 let b = EvidenceBundle {
297 agent_action_log: EvidenceState::complete(log_with(vec![action("rm -rf /tmp")])),
298 ..Default::default()
299 };
300 let findings = PrivilegedOperationAuditControl.evaluate(&b);
301 assert_eq!(findings[0].status, ControlStatus::Violated);
302 assert!(findings[0].subjects[0].contains("rm -rf /tmp"));
303 }
304
305 #[test]
306 fn terraform_destroy_from_action_log() {
307 let b = EvidenceBundle {
308 agent_action_log: EvidenceState::complete(log_with(vec![action(
309 "terraform destroy -auto-approve",
310 )])),
311 ..Default::default()
312 };
313 let findings = PrivilegedOperationAuditControl.evaluate(&b);
314 assert_eq!(findings[0].status, ControlStatus::Violated);
315 }
316
317 #[test]
318 fn safe_commands_satisfied() {
319 let b = EvidenceBundle {
320 agent_action_log: EvidenceState::complete(log_with(vec![
321 action("cargo build"),
322 action("git commit -m 'fix'"),
323 ])),
324 privileged_git_events: EvidenceState::complete(vec![]),
325 ..Default::default()
326 };
327 let findings = PrivilegedOperationAuditControl.evaluate(&b);
328 assert_eq!(findings[0].status, ControlStatus::Satisfied);
329 }
330
331 #[test]
332 fn custom_patterns_from_spec() {
333 let b = EvidenceBundle {
334 agent_action_log: EvidenceState::complete(log_with(vec![action(
335 "vault delete secret/prod",
336 )])),
337 agent_spec: EvidenceState::complete(AgentSpec {
338 custom_destructive_patterns: vec!["vault delete".to_string()],
339 ..Default::default()
340 }),
341 ..Default::default()
342 };
343 let findings = PrivilegedOperationAuditControl.evaluate(&b);
344 assert_eq!(findings[0].status, ControlStatus::Violated);
345 }
346
347 #[test]
350 fn git_events_and_action_log_combined() {
351 let b = EvidenceBundle {
352 privileged_git_events: EvidenceState::complete(vec![git_event(
353 "bot",
354 PrivilegedAction::ForcePush,
355 Some("main"),
356 None,
357 )]),
358 agent_action_log: EvidenceState::complete(log_with(vec![action("DROP TABLE users")])),
359 ..Default::default()
360 };
361 let findings = PrivilegedOperationAuditControl.evaluate(&b);
362 assert_eq!(findings[0].status, ControlStatus::Violated);
363 assert_eq!(findings[0].subjects.len(), 2);
364 }
365
366 #[test]
369 fn both_missing_indeterminate() {
370 let b = EvidenceBundle {
371 privileged_git_events: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
372 source: "webhook".into(),
373 subject: "events".into(),
374 detail: "down".into(),
375 }]),
376 agent_action_log: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
377 source: "monitor".into(),
378 subject: "log".into(),
379 detail: "down".into(),
380 }]),
381 ..Default::default()
382 };
383 let findings = PrivilegedOperationAuditControl.evaluate(&b);
384 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
385 assert_eq!(findings[0].evidence_gaps.len(), 2);
386 }
387
388 #[test]
389 fn both_not_applicable() {
390 let b = EvidenceBundle {
391 privileged_git_events: EvidenceState::not_applicable(),
392 agent_action_log: EvidenceState::not_applicable(),
393 ..Default::default()
394 };
395 let findings = PrivilegedOperationAuditControl.evaluate(&b);
396 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
397 }
398
399 #[test]
400 fn one_missing_one_present_still_evaluates() {
401 let b = EvidenceBundle {
402 privileged_git_events: EvidenceState::missing(vec![]),
403 agent_action_log: EvidenceState::complete(log_with(vec![action("rm -rf /")])),
404 ..Default::default()
405 };
406 let findings = PrivilegedOperationAuditControl.evaluate(&b);
407 assert_eq!(findings[0].status, ControlStatus::Violated);
408 }
409
410 #[test]
411 fn case_insensitive_matching() {
412 let b = EvidenceBundle {
413 agent_action_log: EvidenceState::complete(log_with(vec![action("DROP TABLE users")])),
414 ..Default::default()
415 };
416 let findings = PrivilegedOperationAuditControl.evaluate(&b);
417 assert_eq!(findings[0].status, ControlStatus::Violated);
418 }
419
420 #[test]
421 fn partial_evidence_notes_gaps() {
422 let b = EvidenceBundle {
423 privileged_git_events: EvidenceState::partial(
424 vec![],
425 vec![EvidenceGap::Truncated {
426 source: "webhook".into(),
427 subject: "events".into(),
428 }],
429 ),
430 agent_action_log: EvidenceState::complete(log_with(vec![])),
431 ..Default::default()
432 };
433 let findings = PrivilegedOperationAuditControl.evaluate(&b);
434 assert_eq!(findings[0].status, ControlStatus::Satisfied);
435 assert!(findings[0].rationale.contains("partial evidence"));
436 }
437}