1use std::collections::HashSet;
2
3use crate::policy::{ActionPattern, ScopedPolicy};
4
5#[derive(Debug, Clone)]
7pub struct DriftReport {
8 pub granted: Vec<String>,
10 pub used: Vec<String>,
12 pub unused: Vec<String>,
14 pub over_provisioned: bool,
16 pub suggestion: Option<String>,
18}
19
20pub 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 if let Ok(pattern) = ActionPattern::parse(g) {
30 !used_actions.iter().any(|u| {
31 if let Ok(used_pat) = ActionPattern::parse(u) {
32 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
62pub fn detect_used_actions(command: &[String], stderr: &str) -> Vec<String> {
68 let mut actions = HashSet::new();
69
70 if let Some(cmd_actions) = infer_from_command(command) {
72 actions.extend(cmd_actions);
73 }
74
75 for line in stderr.lines() {
77 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 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 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
116fn 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 if cmd == "aws" || cmd.ends_with("/aws") {
127 return infer_aws_cli_actions(&args);
128 }
129
130 if (cmd == "terraform" || cmd.ends_with("/terraform")) && args.first() == Some(&"plan") {
132 return Some(vec!["sts:GetCallerIdentity".into()]);
133 }
134
135 None
136}
137
138fn 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
182fn 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
218fn parse_sdk_request_line(line: &str) -> Option<String> {
220 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
236pub 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 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 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}