1use std::collections::HashSet;
2
3use crate::policy::{ActionPattern, ScopedPolicy};
4use crate::session::CloudProvider;
5
6#[derive(Debug, Clone)]
8pub struct DriftReport {
9 pub granted: Vec<String>,
11 pub used: Vec<String>,
13 pub unused: Vec<String>,
15 pub over_provisioned: bool,
17 pub suggestion: Option<String>,
19}
20
21pub 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 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
85fn 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
101fn 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
117pub fn detect_used_actions(command: &[String], stderr: &str) -> Vec<String> {
123 let mut actions = HashSet::new();
124
125 if let Some(cmd_actions) = infer_from_command(command) {
127 actions.extend(cmd_actions);
128 }
129
130 for line in stderr.lines() {
132 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 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 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
171fn 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 if cmd == "aws" || cmd.ends_with("/aws") {
182 return infer_aws_cli_actions(&args);
183 }
184
185 if cmd == "gcloud" || cmd.ends_with("/gcloud") {
187 return infer_gcloud_cli_actions(&args);
188 }
189
190 if (cmd == "terraform" || cmd.ends_with("/terraform")) && args.first() == Some(&"plan") {
192 return Some(vec!["sts:GetCallerIdentity".into()]);
193 }
194
195 None
196}
197
198fn 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
242fn 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
279fn 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
315fn parse_sdk_request_line(line: &str) -> Option<String> {
317 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
333pub 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 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 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}