Skip to main content

tryaudex_core/
drift.rs

1use std::collections::HashSet;
2
3use crate::policy::{ActionPattern, ScopedPolicy};
4use crate::session::CloudProvider;
5
6/// Result of comparing granted permissions against detected usage.
7#[derive(Debug, Clone)]
8pub struct DriftReport {
9    /// Actions that were granted by the policy.
10    pub granted: Vec<String>,
11    /// Actions that were detected as actually used.
12    pub used: Vec<String>,
13    /// Actions granted but never detected as used (over-provisioned).
14    pub unused: Vec<String>,
15    /// Whether the policy appears over-provisioned.
16    pub over_provisioned: bool,
17    /// Suggested tighter policy string (for `--allow`).
18    pub suggestion: Option<String>,
19}
20
21/// Analyze drift between granted policy and detected usage.
22/// Uses provider-appropriate action format for comparison.
23pub fn analyze(
24    policy: &ScopedPolicy,
25    used_actions: &[String],
26    provider: CloudProvider,
27) -> DriftReport {
28    let granted: Vec<String> = policy
29        .actions
30        .iter()
31        .map(|a| match provider {
32            CloudProvider::Gcp => a.to_gcp_permission(),
33            CloudProvider::Azure => a.to_azure_permission(),
34            CloudProvider::Aws => a.to_iam_action(),
35        })
36        .collect();
37
38    // R6-M5: `ActionPattern::parse` is AWS-specific (`service:action` with
39    // a single colon delimiter). Calling it on GCP permissions like
40    // `storage.objects.get` or Azure actions like
41    // `Microsoft.Storage/storageAccounts/read` fails, so the old code
42    // flagged every GCP/Azure granted permission as "unused" and drift
43    // detection was silently broken for non-AWS. Branch on provider and
44    // do a format-appropriate wildcard/exact comparison for the other
45    // two clouds.
46    let unused: Vec<String> = granted
47        .iter()
48        .filter(|g| match provider {
49            CloudProvider::Aws => {
50                if let Ok(pattern) = ActionPattern::parse(g) {
51                    !used_actions.iter().any(|u| {
52                        if let Ok(used_pat) = ActionPattern::parse(u) {
53                            pattern.matches(&used_pat)
54                        } else {
55                            false
56                        }
57                    })
58                } else {
59                    true
60                }
61            }
62            CloudProvider::Gcp => !used_actions.iter().any(|u| gcp_matches(g, u)),
63            CloudProvider::Azure => !used_actions.iter().any(|u| azure_matches(g, u)),
64        })
65        .cloned()
66        .collect();
67
68    let over_provisioned = !unused.is_empty() && !used_actions.is_empty();
69
70    let suggestion = if over_provisioned && !used_actions.is_empty() {
71        Some(used_actions.join(","))
72    } else {
73        None
74    };
75
76    DriftReport {
77        granted,
78        used: used_actions.to_vec(),
79        unused,
80        over_provisioned,
81        suggestion,
82    }
83}
84
85/// GCP permission wildcard/exact match. GCP permissions are `service.resource.verb`,
86/// e.g. `storage.objects.get`. A trailing `*` (e.g. `storage.objects.*`) matches
87/// any permission that starts with the prefix up to the last `.`.
88fn gcp_matches(granted: &str, used: &str) -> bool {
89    if granted == used {
90        return true;
91    }
92    if let Some(prefix) = granted.strip_suffix(".*") {
93        used.starts_with(prefix) && used[prefix.len()..].starts_with('.')
94    } else if let Some(prefix) = granted.strip_suffix('*') {
95        used.starts_with(prefix)
96    } else {
97        false
98    }
99}
100
101/// Azure RBAC action wildcard/exact match. Azure actions use `/` separators
102/// (e.g. `Microsoft.Storage/storageAccounts/read`) and support `*` as a
103/// segment wildcard — a trailing `/*` matches any suffix segments.
104fn azure_matches(granted: &str, used: &str) -> bool {
105    if granted == used {
106        return true;
107    }
108    if let Some(prefix) = granted.strip_suffix("/*") {
109        used.starts_with(prefix) && used[prefix.len()..].starts_with('/')
110    } else if let Some(prefix) = granted.strip_suffix('*') {
111        used.starts_with(prefix)
112    } else {
113        false
114    }
115}
116
117/// Detect which IAM actions were likely used by parsing the subprocess command
118/// and its stderr output.
119///
120/// This uses heuristics — it can't catch every API call, but covers common
121/// patterns from the AWS CLI, GCP gcloud, and verbose SDK output.
122pub fn detect_used_actions(command: &[String], stderr: &str) -> Vec<String> {
123    let mut actions = HashSet::new();
124
125    // 1. Infer from the command itself
126    if let Some(cmd_actions) = infer_from_command(command) {
127        actions.extend(cmd_actions);
128    }
129
130    // 2. Parse stderr for AWS CLI/SDK debug output
131    for line in stderr.lines() {
132        // AWS CLI debug: "Making request for OperationModel(name=GetObject)"
133        if let Some(pos) = line.find("OperationModel(name=") {
134            let rest = &line[pos + 20..];
135            if let Some(end) = rest.find(')') {
136                let op = &rest[..end];
137                if let Some(service) = guess_service_from_context(line) {
138                    actions.insert(format!("{}:{}", service, op));
139                }
140            }
141        }
142
143        // AWS SDK: "Request: service/operation"
144        if line.contains("Request:") || line.contains("request to") {
145            if let Some(action) = parse_sdk_request_line(line) {
146                actions.insert(action);
147            }
148        }
149
150        // Pattern: "Calling service:Action" or "called service:Action"
151        for prefix in &["Calling ", "called ", "invoking "] {
152            if let Some(pos) = line.to_lowercase().find(prefix) {
153                let rest = &line[pos + prefix.len()..];
154                if let Some(action) = rest.split_whitespace().next() {
155                    if action.contains(':') && action.len() > 3 {
156                        let clean = action.trim_end_matches(|c: char| {
157                            !c.is_alphanumeric() && c != ':' && c != '*'
158                        });
159                        actions.insert(clean.to_string());
160                    }
161                }
162            }
163        }
164    }
165
166    let mut result: Vec<String> = actions.into_iter().collect();
167    result.sort();
168    result
169}
170
171/// Infer IAM actions from the command line arguments.
172fn infer_from_command(command: &[String]) -> Option<Vec<String>> {
173    if command.is_empty() {
174        return None;
175    }
176
177    let cmd = command[0].as_str();
178    let args: Vec<&str> = command.iter().skip(1).map(|s| s.as_str()).collect();
179
180    // AWS CLI commands
181    if cmd == "aws" || cmd.ends_with("/aws") {
182        return infer_aws_cli_actions(&args);
183    }
184
185    // gcloud CLI commands
186    if cmd == "gcloud" || cmd.ends_with("/gcloud") {
187        return infer_gcloud_cli_actions(&args);
188    }
189
190    // Terraform
191    if (cmd == "terraform" || cmd.ends_with("/terraform")) && args.first() == Some(&"plan") {
192        return Some(vec!["sts:GetCallerIdentity".into()]);
193    }
194
195    None
196}
197
198/// Map AWS CLI subcommands to IAM actions.
199fn infer_aws_cli_actions(args: &[&str]) -> Option<Vec<String>> {
200    if args.len() < 2 {
201        return None;
202    }
203
204    let service = args[0];
205    let operation = args[1];
206
207    let strs: Vec<&str> = match (service, operation) {
208        ("s3", "ls") => vec!["s3:ListBucket", "s3:ListAllMyBuckets"],
209        ("s3", "cp") => {
210            if args.iter().any(|a| a.starts_with("s3://")) {
211                if args.len() > 3 && args[3].starts_with("s3://") {
212                    vec!["s3:GetObject", "s3:PutObject"]
213                } else if args.get(2).is_some_and(|a| a.starts_with("s3://")) {
214                    vec!["s3:GetObject"]
215                } else {
216                    vec!["s3:PutObject"]
217                }
218            } else {
219                vec!["s3:GetObject", "s3:PutObject"]
220            }
221        }
222        ("s3", "rm") => vec!["s3:DeleteObject"],
223        ("s3", "sync") => vec!["s3:ListBucket", "s3:GetObject", "s3:PutObject"],
224        ("s3", "mb") => vec!["s3:CreateBucket"],
225        ("s3api", op) => return Some(vec![format!("s3:{}", op)]),
226        ("lambda", "invoke") => vec!["lambda:InvokeFunction"],
227        ("lambda", "update-function-code") => vec!["lambda:UpdateFunctionCode"],
228        ("lambda", "get-function") => vec!["lambda:GetFunction"],
229        ("lambda", "list-functions") => vec!["lambda:ListFunctions"],
230        ("dynamodb", "get-item") => vec!["dynamodb:GetItem"],
231        ("dynamodb", "put-item") => vec!["dynamodb:PutItem"],
232        ("dynamodb", "query") => vec!["dynamodb:Query"],
233        ("dynamodb", "scan") => vec!["dynamodb:Scan"],
234        ("ec2", "describe-instances") => vec!["ec2:DescribeInstances"],
235        ("sts", "get-caller-identity") => vec!["sts:GetCallerIdentity"],
236        _ => return None,
237    };
238
239    Some(strs.into_iter().map(|s| s.to_string()).collect())
240}
241
242/// Map gcloud CLI subcommands to GCP IAM permissions.
243fn infer_gcloud_cli_actions(args: &[&str]) -> Option<Vec<String>> {
244    if args.len() < 2 {
245        return None;
246    }
247
248    let service = args[0];
249    let operation = args[1];
250    let sub_op = args.get(2).copied().unwrap_or("");
251
252    let perms: Vec<&str> = match (service, operation, sub_op) {
253        ("storage", "ls", _) => vec!["storage.objects.list", "storage.buckets.list"],
254        ("storage", "cp", _) => vec!["storage.objects.get", "storage.objects.create"],
255        ("storage", "rm", _) => vec!["storage.objects.delete"],
256        ("storage", "cat", _) => vec!["storage.objects.get"],
257        ("compute", "instances", "list") => vec!["compute.instances.list"],
258        ("compute", "instances", "create") => vec!["compute.instances.create"],
259        ("compute", "instances", "delete") => vec!["compute.instances.delete"],
260        ("compute", "instances", "describe") => vec!["compute.instances.get"],
261        ("functions", "deploy", _) => {
262            vec![
263                "cloudfunctions.functions.create",
264                "cloudfunctions.functions.update",
265            ]
266        }
267        ("functions", "list", _) => vec!["cloudfunctions.functions.list"],
268        ("functions", "call", _) => vec!["cloudfunctions.functions.call"],
269        ("run", "deploy", _) => vec!["run.services.create", "run.services.update"],
270        ("run", "services", "list") => vec!["run.services.list"],
271        ("pubsub", "topics", "publish") => vec!["pubsub.topics.publish"],
272        ("pubsub", "topics", "list") => vec!["pubsub.topics.list"],
273        _ => return None,
274    };
275
276    Some(perms.into_iter().map(|s| s.to_string()).collect())
277}
278
279/// Try to guess the AWS service from context in a log line.
280fn guess_service_from_context(line: &str) -> Option<&'static str> {
281    let lower = line.to_lowercase();
282    if lower.contains("s3") {
283        return Some("s3");
284    }
285    if lower.contains("lambda") {
286        return Some("lambda");
287    }
288    if lower.contains("dynamodb") {
289        return Some("dynamodb");
290    }
291    if lower.contains("ec2") {
292        return Some("ec2");
293    }
294    if lower.contains("iam") {
295        return Some("iam");
296    }
297    if lower.contains("sts") {
298        return Some("sts");
299    }
300    if lower.contains("sqs") {
301        return Some("sqs");
302    }
303    if lower.contains("sns") {
304        return Some("sns");
305    }
306    if lower.contains("cloudwatch") || lower.contains("logs") {
307        return Some("logs");
308    }
309    if lower.contains("cloudformation") {
310        return Some("cloudformation");
311    }
312    None
313}
314
315/// Parse a line that looks like "Request: service/operation" or similar patterns.
316fn parse_sdk_request_line(line: &str) -> Option<String> {
317    // Pattern: "service/OperationName"
318    for sep in &["Request: ", "request to "] {
319        if let Some(pos) = line.find(sep) {
320            let rest = &line[pos + sep.len()..];
321            let token = rest.split_whitespace().next()?;
322            if token.contains('/') {
323                let parts: Vec<&str> = token.splitn(2, '/').collect();
324                if parts.len() == 2 {
325                    return Some(format!("{}:{}", parts[0], parts[1]));
326                }
327            }
328        }
329    }
330    None
331}
332
333/// Format a drift report as human-readable text.
334pub fn format_report(report: &DriftReport) -> String {
335    let mut out = String::new();
336
337    if !report.over_provisioned {
338        out.push_str("No policy drift detected.\n");
339        return out;
340    }
341
342    out.push_str(&format!(
343        "Policy drift detected: {} of {} granted actions unused\n",
344        report.unused.len(),
345        report.granted.len()
346    ));
347
348    if !report.used.is_empty() {
349        out.push_str("  Used:   ");
350        out.push_str(&report.used.join(", "));
351        out.push('\n');
352    }
353
354    if !report.unused.is_empty() {
355        out.push_str("  Unused: ");
356        out.push_str(&report.unused.join(", "));
357        out.push('\n');
358    }
359
360    if let Some(ref suggestion) = report.suggestion {
361        out.push_str(&format!("  Suggested --allow: \"{}\"\n", suggestion));
362    }
363
364    out
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_no_drift_when_all_used() {
373        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:ListBucket").unwrap();
374        let used = vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()];
375        let report = analyze(&policy, &used, CloudProvider::Aws);
376        assert!(!report.over_provisioned);
377        assert!(report.unused.is_empty());
378        assert!(report.suggestion.is_none());
379    }
380
381    #[test]
382    fn test_drift_with_unused_actions() {
383        let policy =
384            ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject,s3:DeleteObject").unwrap();
385        let used = vec!["s3:GetObject".to_string()];
386        let report = analyze(&policy, &used, CloudProvider::Aws);
387        assert!(report.over_provisioned);
388        assert_eq!(report.unused.len(), 2);
389        assert!(report.unused.contains(&"s3:PutObject".to_string()));
390        assert!(report.unused.contains(&"s3:DeleteObject".to_string()));
391        assert_eq!(report.suggestion, Some("s3:GetObject".to_string()));
392    }
393
394    #[test]
395    fn test_wildcard_covers_specific_action() {
396        let policy = ScopedPolicy::from_allow_str("s3:*").unwrap();
397        let used = vec!["s3:GetObject".to_string()];
398        let report = analyze(&policy, &used, CloudProvider::Aws);
399        // s3:* covers s3:GetObject, so it's not "unused"
400        assert!(!report.over_provisioned);
401    }
402
403    #[test]
404    fn test_no_drift_when_no_usage_detected() {
405        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
406        let used: Vec<String> = vec![];
407        let report = analyze(&policy, &used, CloudProvider::Aws);
408        // No usage detected — can't determine drift
409        assert!(!report.over_provisioned);
410    }
411
412    #[test]
413    fn test_infer_aws_s3_ls() {
414        let cmd = vec!["aws".to_string(), "s3".to_string(), "ls".to_string()];
415        let actions = detect_used_actions(&cmd, "");
416        assert!(actions.contains(&"s3:ListBucket".to_string()));
417        assert!(actions.contains(&"s3:ListAllMyBuckets".to_string()));
418    }
419
420    #[test]
421    fn test_infer_aws_s3_cp() {
422        let cmd = vec![
423            "aws".to_string(),
424            "s3".to_string(),
425            "cp".to_string(),
426            "s3://bucket/key".to_string(),
427            "local.txt".to_string(),
428        ];
429        let actions = detect_used_actions(&cmd, "");
430        assert!(actions.contains(&"s3:GetObject".to_string()));
431    }
432
433    #[test]
434    fn test_infer_lambda_invoke() {
435        let cmd = vec![
436            "aws".to_string(),
437            "lambda".to_string(),
438            "invoke".to_string(),
439            "--function-name".to_string(),
440            "my-func".to_string(),
441            "out.json".to_string(),
442        ];
443        let actions = detect_used_actions(&cmd, "");
444        assert!(actions.contains(&"lambda:InvokeFunction".to_string()));
445    }
446
447    #[test]
448    fn test_detect_from_stderr_debug() {
449        let stderr =
450            r#"2024-01-15 DEBUG Making request for OperationModel(name=GetObject) with S3 client"#;
451        let actions = detect_used_actions(&[], stderr);
452        assert!(actions.contains(&"s3:GetObject".to_string()));
453    }
454
455    #[test]
456    fn test_format_report_no_drift() {
457        let policy = ScopedPolicy::from_allow_str("s3:GetObject").unwrap();
458        let used = vec!["s3:GetObject".to_string()];
459        let report = analyze(&policy, &used, CloudProvider::Aws);
460        let text = format_report(&report);
461        assert!(text.contains("No policy drift"));
462    }
463
464    #[test]
465    fn test_format_report_with_drift() {
466        let policy = ScopedPolicy::from_allow_str("s3:GetObject,s3:PutObject").unwrap();
467        let used = vec!["s3:GetObject".to_string()];
468        let report = analyze(&policy, &used, CloudProvider::Aws);
469        let text = format_report(&report);
470        assert!(text.contains("drift detected"));
471        assert!(text.contains("s3:PutObject"));
472    }
473}