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| {
102                            !c.is_alphanumeric() && c != ':' && c != '*'
103                        });
104                        actions.insert(clean.to_string());
105                    }
106                }
107            }
108        }
109    }
110
111    let mut result: Vec<String> = actions.into_iter().collect();
112    result.sort();
113    result
114}
115
116/// Infer IAM actions from the command line arguments.
117fn infer_from_command(command: &[String]) -> Option<Vec<String>> {
118    if command.is_empty() {
119        return None;
120    }
121
122    let cmd = command[0].as_str();
123    let args: Vec<&str> = command.iter().skip(1).map(|s| s.as_str()).collect();
124
125    // AWS CLI commands
126    if cmd == "aws" || cmd.ends_with("/aws") {
127        return infer_aws_cli_actions(&args);
128    }
129
130    // Terraform
131    if (cmd == "terraform" || cmd.ends_with("/terraform")) && args.first() == Some(&"plan") {
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") {
186        return Some("s3");
187    }
188    if lower.contains("lambda") {
189        return Some("lambda");
190    }
191    if lower.contains("dynamodb") {
192        return Some("dynamodb");
193    }
194    if lower.contains("ec2") {
195        return Some("ec2");
196    }
197    if lower.contains("iam") {
198        return Some("iam");
199    }
200    if lower.contains("sts") {
201        return Some("sts");
202    }
203    if lower.contains("sqs") {
204        return Some("sqs");
205    }
206    if lower.contains("sns") {
207        return Some("sns");
208    }
209    if lower.contains("cloudwatch") || lower.contains("logs") {
210        return Some("logs");
211    }
212    if lower.contains("cloudformation") {
213        return Some("cloudformation");
214    }
215    None
216}
217
218/// Parse a line that looks like "Request: service/operation" or similar patterns.
219fn parse_sdk_request_line(line: &str) -> Option<String> {
220    // Pattern: "service/OperationName"
221    for sep in &["Request: ", "request to "] {
222        if let Some(pos) = line.find(sep) {
223            let rest = &line[pos + sep.len()..];
224            let token = rest.split_whitespace().next()?;
225            if token.contains('/') {
226                let parts: Vec<&str> = token.splitn(2, '/').collect();
227                if parts.len() == 2 {
228                    return Some(format!("{}:{}", parts[0], parts[1]));
229                }
230            }
231        }
232    }
233    None
234}
235
236/// Format a drift report as human-readable text.
237pub fn format_report(report: &DriftReport) -> String {
238    let mut out = String::new();
239
240    if !report.over_provisioned {
241        out.push_str("No policy drift detected.\n");
242        return out;
243    }
244
245    out.push_str(&format!(
246        "Policy drift detected: {} of {} granted actions unused\n",
247        report.unused.len(),
248        report.granted.len()
249    ));
250
251    if !report.used.is_empty() {
252        out.push_str("  Used:   ");
253        out.push_str(&report.used.join(", "));
254        out.push('\n');
255    }
256
257    if !report.unused.is_empty() {
258        out.push_str("  Unused: ");
259        out.push_str(&report.unused.join(", "));
260        out.push('\n');
261    }
262
263    if let Some(ref suggestion) = report.suggestion {
264        out.push_str(&format!("  Suggested --allow: \"{}\"\n", suggestion));
265    }
266
267    out
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn test_no_drift_when_all_used() {
276        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
277        let used = vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()];
278        let report = analyze(&policy, &used);
279        assert!(!report.over_provisioned);
280        assert!(report.unused.is_empty());
281        assert!(report.suggestion.is_none());
282    }
283
284    #[test]
285    fn test_drift_with_unused_actions() {
286        let policy =
287            ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject,s3:DeleteObject").unwrap();
288        let used = vec!["s3:GetObject".to_string()];
289        let report = analyze(&policy, &used);
290        assert!(report.over_provisioned);
291        assert_eq!(report.unused.len(), 2);
292        assert!(report.unused.contains(&"s3:PutObject".to_string()));
293        assert!(report.unused.contains(&"s3:DeleteObject".to_string()));
294        assert_eq!(report.suggestion, Some("s3:GetObject".to_string()));
295    }
296
297    #[test]
298    fn test_wildcard_covers_specific_action() {
299        let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
300        let used = vec!["s3:GetObject".to_string()];
301        let report = analyze(&policy, &used);
302        // s3:* covers s3:GetObject, so it's not "unused"
303        assert!(!report.over_provisioned);
304    }
305
306    #[test]
307    fn test_no_drift_when_no_usage_detected() {
308        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
309        let used: Vec<String> = vec![];
310        let report = analyze(&policy, &used);
311        // No usage detected — can't determine drift
312        assert!(!report.over_provisioned);
313    }
314
315    #[test]
316    fn test_infer_aws_s3_ls() {
317        let cmd = vec!["aws".to_string(), "s3".to_string(), "ls".to_string()];
318        let actions = detect_used_actions(&cmd, "");
319        assert!(actions.contains(&"s3:ListBucket".to_string()));
320        assert!(actions.contains(&"s3:ListAllMyBuckets".to_string()));
321    }
322
323    #[test]
324    fn test_infer_aws_s3_cp() {
325        let cmd = vec![
326            "aws".to_string(),
327            "s3".to_string(),
328            "cp".to_string(),
329            "s3://bucket/key".to_string(),
330            "local.txt".to_string(),
331        ];
332        let actions = detect_used_actions(&cmd, "");
333        assert!(actions.contains(&"s3:GetObject".to_string()));
334    }
335
336    #[test]
337    fn test_infer_lambda_invoke() {
338        let cmd = vec![
339            "aws".to_string(),
340            "lambda".to_string(),
341            "invoke".to_string(),
342            "--function-name".to_string(),
343            "my-func".to_string(),
344            "out.json".to_string(),
345        ];
346        let actions = detect_used_actions(&cmd, "");
347        assert!(actions.contains(&"lambda:InvokeFunction".to_string()));
348    }
349
350    #[test]
351    fn test_detect_from_stderr_debug() {
352        let stderr =
353            r#"2024-01-15 DEBUG Making request for OperationModel(name=GetObject) with S3 client"#;
354        let actions = detect_used_actions(&[], stderr);
355        assert!(actions.contains(&"s3:GetObject".to_string()));
356    }
357
358    #[test]
359    fn test_format_report_no_drift() {
360        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
361        let used = vec!["s3:GetObject".to_string()];
362        let report = analyze(&policy, &used);
363        let text = format_report(&report);
364        assert!(text.contains("No policy drift"));
365    }
366
367    #[test]
368    fn test_format_report_with_drift() {
369        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
370        let used = vec!["s3:GetObject".to_string()];
371        let report = analyze(&policy, &used);
372        let text = format_report(&report);
373        assert!(text.contains("drift detected"));
374        assert!(text.contains("s3:PutObject"));
375    }
376}