Skip to main content

libverify_core/controls/
privileged_operation_audit.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState};
3
4/// Patterns matched case-insensitively against agent action commands.
5/// Users can extend via `AgentSpec.custom_destructive_patterns`.
6pub const NOTABLE_COMMAND_PATTERNS: &[&str] = &[
7    // Filesystem
8    "rm -rf",
9    "rm -r",
10    "rm -fr",
11    "shred ",
12    "find / -delete",
13    "find . -delete",
14    // SQL
15    "drop table",
16    "drop database",
17    "drop schema",
18    "truncate table",
19    "delete from",
20    // Git history mutation
21    "git push --force",
22    "git push -f",
23    "git reset --hard",
24    "git push origin :",
25    // Container/orchestration
26    "kubectl delete",
27    "kubectl drain",
28    "helm uninstall",
29    "helm delete",
30    "docker rm",
31    "docker system prune",
32    "docker-compose down -v",
33    // Infrastructure
34    "terraform destroy",
35    "pulumi destroy",
36    // Cloud provider
37    "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    // System administration
47    "chmod 000",
48    "chmod -r 000",
49    "iptables -f",
50    "systemctl stop",
51    "kill -9",
52    "format c:",
53];
54
55/// Surfaces privileged operations from two evidence sources:
56/// 1. Structured git events (force push, admin bypass, tag/branch deletion)
57/// 2. Agent action log commands matched against notable patterns
58///
59/// This control does not enforce policy — it makes operations visible.
60/// The OPA profile decides whether each finding is pass/review/fail.
61pub 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        // --- Source 1: Structured git events ---
77        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        // --- Source 2: Agent action log command patterns ---
116        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        // --- Both sources missing = Indeterminate ---
143        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        // --- Produce finding ---
175        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    // --- Git events ---
233
234    #[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    // --- Action log command patterns ---
293
294    #[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    // --- Combined sources ---
348
349    #[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    // --- Missing/NotApplicable ---
367
368    #[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}