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| !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
114fn 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 if cmd == "aws" || cmd.ends_with("/aws") {
125 return infer_aws_cli_actions(&args);
126 }
127
128 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
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") { 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
198fn parse_sdk_request_line(line: &str) -> Option<String> {
200 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
216pub 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 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 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}