Skip to main content

tryaudex_core/
drift.rs

1use std::collections::HashSet;
2
3use crate::policy::{ActionPattern, ScopedPolicy};
4
5/// Result of comparing granted permissions against detected usage.
6#[derive(Debug, Clone)]
7pub struct DriftReport {
8    /// Actions that were granted by the policy.
9    pub granted: Vec<String>,
10    /// Actions that were detected as actually used.
11    pub used: Vec<String>,
12    /// Actions granted but never detected as used (over-provisioned).
13    pub unused: Vec<String>,
14    /// Whether the policy appears over-provisioned.
15    pub over_provisioned: bool,
16    /// Suggested tighter policy string (for `--allow`).
17    pub suggestion: Option<String>,
18}
19
20/// Analyze drift between granted policy and detected usage.
21pub fn analyze(policy: &ScopedPolicy, used_actions: &[String]) -> DriftReport {
22    let granted: Vec<String> = policy.actions.iter().map(|a| a.to_iam_action()).collect();
23
24    let unused: Vec<String> = granted
25        .iter()
26        .filter(|g| {
27            // A granted action is "unused" if no used action matches it
28            // For wildcards like "s3:*", check if any used action is under that service
29            if let Ok(pattern) = ActionPattern::parse(g) {
30                !used_actions.iter().any(|u| {
31                    if let Ok(used_pat) = ActionPattern::parse(u) {
32                        // The granted pattern covers this used action
33                        pattern.matches(&used_pat)
34                    } else {
35                        false
36                    }
37                })
38            } else {
39                true
40            }
41        })
42        .cloned()
43        .collect();
44
45    let over_provisioned = !unused.is_empty() && !used_actions.is_empty();
46
47    let suggestion = if over_provisioned && !used_actions.is_empty() {
48        Some(used_actions.join(","))
49    } else {
50        None
51    };
52
53    DriftReport {
54        granted,
55        used: used_actions.to_vec(),
56        unused,
57        over_provisioned,
58        suggestion,
59    }
60}
61
62/// Detect which IAM actions were likely used by parsing the subprocess command
63/// and its stderr output.
64///
65/// This uses heuristics — it can't catch every API call, but covers common
66/// patterns from the AWS CLI, GCP gcloud, and verbose SDK output.
67pub fn detect_used_actions(command: &[String], stderr: &str) -> Vec<String> {
68    let mut actions = HashSet::new();
69
70    // 1. Infer from the command itself
71    if let Some(cmd_actions) = infer_from_command(command) {
72        actions.extend(cmd_actions);
73    }
74
75    // 2. Parse stderr for AWS CLI/SDK debug output
76    for line in stderr.lines() {
77        // AWS CLI debug: "Making request for OperationModel(name=GetObject)"
78        if let Some(pos) = line.find("OperationModel(name=") {
79            let rest = &line[pos + 20..];
80            if let Some(end) = rest.find(')') {
81                let op = &rest[..end];
82                if let Some(service) = guess_service_from_context(line) {
83                    actions.insert(format!("{}:{}", service, op));
84                }
85            }
86        }
87
88        // AWS SDK: "Request: service/operation"
89        if line.contains("Request:") || line.contains("request to") {
90            if let Some(action) = parse_sdk_request_line(line) {
91                actions.insert(action);
92            }
93        }
94
95        // Pattern: "Calling service:Action" or "called service:Action"
96        for prefix in &["Calling ", "called ", "invoking "] {
97            if let Some(pos) = line.to_lowercase().find(prefix) {
98                let rest = &line[pos + prefix.len()..];
99                if let Some(action) = rest.split_whitespace().next() {
100                    if action.contains(':') && action.len() > 3 {
101                        let clean = action.trim_end_matches(|c: char| !c.is_alphanumeric() && c != ':' && c != '*');
102                        actions.insert(clean.to_string());
103                    }
104                }
105            }
106        }
107    }
108
109    let mut result: Vec<String> = actions.into_iter().collect();
110    result.sort();
111    result
112}
113
114/// Infer IAM actions from the command line arguments.
115fn infer_from_command(command: &[String]) -> Option<Vec<String>> {
116    if command.is_empty() {
117        return None;
118    }
119
120    let cmd = command[0].as_str();
121    let args: Vec<&str> = command.iter().skip(1).map(|s| s.as_str()).collect();
122
123    // AWS CLI commands
124    if cmd == "aws" || cmd.ends_with("/aws") {
125        return infer_aws_cli_actions(&args);
126    }
127
128    // Terraform
129    if (cmd == "terraform" || cmd.ends_with("/terraform"))
130        && args.first() == Some(&"plan")
131    {
132        return Some(vec!["sts:GetCallerIdentity".into()]);
133    }
134
135    None
136}
137
138/// Map AWS CLI subcommands to IAM actions.
139fn infer_aws_cli_actions(args: &[&str]) -> Option<Vec<String>> {
140    if args.len() < 2 {
141        return None;
142    }
143
144    let service = args[0];
145    let operation = args[1];
146
147    let strs: Vec<&str> = match (service, operation) {
148        ("s3", "ls") => vec!["s3:ListBucket", "s3:ListAllMyBuckets"],
149        ("s3", "cp") => {
150            if args.iter().any(|a| a.starts_with("s3://")) {
151                if args.len() > 3 && args[3].starts_with("s3://") {
152                    vec!["s3:GetObject", "s3:PutObject"]
153                } else if args.get(2).is_some_and(|a| a.starts_with("s3://")) {
154                    vec!["s3:GetObject"]
155                } else {
156                    vec!["s3:PutObject"]
157                }
158            } else {
159                vec!["s3:GetObject", "s3:PutObject"]
160            }
161        }
162        ("s3", "rm") => vec!["s3:DeleteObject"],
163        ("s3", "sync") => vec!["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
164        ("s3", "mb") => vec!["s3:CreateBucket"],
165        ("s3api", op) => return Some(vec![format!("s3:{}", op)]),
166        ("lambda", "invoke") => vec!["lambda:InvokeFunction"],
167        ("lambda", "update-function-code") => vec!["lambda:UpdateFunctionCode"],
168        ("lambda", "get-function") => vec!["lambda:GetFunction"],
169        ("lambda", "list-functions") => vec!["lambda:ListFunctions"],
170        ("dynamodb", "get-item") => vec!["dynamodb:GetItem"],
171        ("dynamodb", "put-item") => vec!["dynamodb:PutItem"],
172        ("dynamodb", "query") => vec!["dynamodb:Query"],
173        ("dynamodb", "scan") => vec!["dynamodb:Scan"],
174        ("ec2", "describe-instances") => vec!["ec2:DescribeInstances"],
175        ("sts", "get-caller-identity") => vec!["sts:GetCallerIdentity"],
176        _ => return None,
177    };
178
179    Some(strs.into_iter().map(|s| s.to_string()).collect())
180}
181
182/// Try to guess the AWS service from context in a log line.
183fn guess_service_from_context(line: &str) -> Option<&'static str> {
184    let lower = line.to_lowercase();
185    if lower.contains("s3") { return Some("s3"); }
186    if lower.contains("lambda") { return Some("lambda"); }
187    if lower.contains("dynamodb") { return Some("dynamodb"); }
188    if lower.contains("ec2") { return Some("ec2"); }
189    if lower.contains("iam") { return Some("iam"); }
190    if lower.contains("sts") { return Some("sts"); }
191    if lower.contains("sqs") { return Some("sqs"); }
192    if lower.contains("sns") { return Some("sns"); }
193    if lower.contains("cloudwatch") || lower.contains("logs") { return Some("logs"); }
194    if lower.contains("cloudformation") { return Some("cloudformation"); }
195    None
196}
197
198/// Parse a line that looks like "Request: service/operation" or similar patterns.
199fn parse_sdk_request_line(line: &str) -> Option<String> {
200    // Pattern: "service/OperationName"
201    for sep in &["Request: ", "request to "] {
202        if let Some(pos) = line.find(sep) {
203            let rest = &line[pos + sep.len()..];
204            let token = rest.split_whitespace().next()?;
205            if token.contains('/') {
206                let parts: Vec<&str> = token.splitn(2, '/').collect();
207                if parts.len() == 2 {
208                    return Some(format!("{}:{}", parts[0], parts[1]));
209                }
210            }
211        }
212    }
213    None
214}
215
216/// Format a drift report as human-readable text.
217pub fn format_report(report: &DriftReport) -> String {
218    let mut out = String::new();
219
220    if !report.over_provisioned {
221        out.push_str("No policy drift detected.\n");
222        return out;
223    }
224
225    out.push_str(&format!(
226        "Policy drift detected: {} of {} granted actions unused\n",
227        report.unused.len(),
228        report.granted.len()
229    ));
230
231    if !report.used.is_empty() {
232        out.push_str("  Used:   ");
233        out.push_str(&report.used.join(", "));
234        out.push('\n');
235    }
236
237    if !report.unused.is_empty() {
238        out.push_str("  Unused: ");
239        out.push_str(&report.unused.join(", "));
240        out.push('\n');
241    }
242
243    if let Some(ref suggestion) = report.suggestion {
244        out.push_str(&format!("  Suggested --allow: \"{}\"\n", suggestion));
245    }
246
247    out
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_no_drift_when_all_used() {
256        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
257        let used = vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()];
258        let report = analyze(&policy, &used);
259        assert!(!report.over_provisioned);
260        assert!(report.unused.is_empty());
261        assert!(report.suggestion.is_none());
262    }
263
264    #[test]
265    fn test_drift_with_unused_actions() {
266        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject,s3:DeleteObject").unwrap();
267        let used = vec!["s3:GetObject".to_string()];
268        let report = analyze(&policy, &used);
269        assert!(report.over_provisioned);
270        assert_eq!(report.unused.len(), 2);
271        assert!(report.unused.contains(&"s3:PutObject".to_string()));
272        assert!(report.unused.contains(&"s3:DeleteObject".to_string()));
273        assert_eq!(report.suggestion, Some("s3:GetObject".to_string()));
274    }
275
276    #[test]
277    fn test_wildcard_covers_specific_action() {
278        let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
279        let used = vec!["s3:GetObject".to_string()];
280        let report = analyze(&policy, &used);
281        // s3:* covers s3:GetObject, so it's not "unused"
282        assert!(!report.over_provisioned);
283    }
284
285    #[test]
286    fn test_no_drift_when_no_usage_detected() {
287        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
288        let used: Vec<String> = vec![];
289        let report = analyze(&policy, &used);
290        // No usage detected — can't determine drift
291        assert!(!report.over_provisioned);
292    }
293
294    #[test]
295    fn test_infer_aws_s3_ls() {
296        let cmd = vec!["aws".to_string(), "s3".to_string(), "ls".to_string()];
297        let actions = detect_used_actions(&cmd, "");
298        assert!(actions.contains(&"s3:ListBucket".to_string()));
299        assert!(actions.contains(&"s3:ListAllMyBuckets".to_string()));
300    }
301
302    #[test]
303    fn test_infer_aws_s3_cp() {
304        let cmd = vec![
305            "aws".to_string(), "s3".to_string(), "cp".to_string(),
306            "s3://bucket/key".to_string(), "local.txt".to_string(),
307        ];
308        let actions = detect_used_actions(&cmd, "");
309        assert!(actions.contains(&"s3:GetObject".to_string()));
310    }
311
312    #[test]
313    fn test_infer_lambda_invoke() {
314        let cmd = vec![
315            "aws".to_string(), "lambda".to_string(), "invoke".to_string(),
316            "--function-name".to_string(), "my-func".to_string(),
317            "out.json".to_string(),
318        ];
319        let actions = detect_used_actions(&cmd, "");
320        assert!(actions.contains(&"lambda:InvokeFunction".to_string()));
321    }
322
323    #[test]
324    fn test_detect_from_stderr_debug() {
325        let stderr = r#"2024-01-15 DEBUG Making request for OperationModel(name=GetObject) with S3 client"#;
326        let actions = detect_used_actions(&[], stderr);
327        assert!(actions.contains(&"s3:GetObject".to_string()));
328    }
329
330    #[test]
331    fn test_format_report_no_drift() {
332        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
333        let used = vec!["s3:GetObject".to_string()];
334        let report = analyze(&policy, &used);
335        let text = format_report(&report);
336        assert!(text.contains("No policy drift"));
337    }
338
339    #[test]
340    fn test_format_report_with_drift() {
341        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
342        let used = vec!["s3:GetObject".to_string()];
343        let report = analyze(&policy, &used);
344        let text = format_report(&report);
345        assert!(text.contains("drift detected"));
346        assert!(text.contains("s3:PutObject"));
347    }
348}