1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::audit::{AuditEntry, AuditEvent, AuditLog};
5use crate::error::Result;
6
7#[derive(Debug, Clone, Copy)]
9pub enum ReportFormat {
10 Json,
12 Csv,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ComplianceRecord {
19 pub timestamp: DateTime<Utc>,
20 pub session_id: String,
21 pub provider: String,
22 pub event_type: String,
23 pub identity: String,
24 pub ttl_seconds: Option<u64>,
25 pub allowed_actions: Option<String>,
26 pub command: Option<String>,
27 pub status: Option<String>,
28 pub duration_seconds: Option<i64>,
29 pub exit_code: Option<i32>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
34pub struct ComplianceReport {
35 pub generated_at: DateTime<Utc>,
36 pub period_start: DateTime<Utc>,
37 pub period_end: DateTime<Utc>,
38 pub total_records: usize,
39 pub total_sessions: usize,
40 pub failed_sessions: usize,
41 pub providers_used: Vec<String>,
42 pub records: Vec<ComplianceRecord>,
43}
44
45fn flatten(entry: &AuditEntry) -> ComplianceRecord {
47 match &entry.event {
48 AuditEvent::SessionCreated {
49 role_arn,
50 ttl_seconds,
51 allowed_actions,
52 command,
53 ..
54 } => ComplianceRecord {
55 timestamp: entry.timestamp,
56 session_id: entry.session_id.clone(),
57 provider: entry.provider.clone(),
58 event_type: "session_created".to_string(),
59 identity: role_arn.clone(),
60 ttl_seconds: Some(*ttl_seconds),
61 allowed_actions: Some(allowed_actions.join(", ")),
62 command: Some(command.join(" ")),
63 status: None,
64 duration_seconds: None,
65 exit_code: None,
66 },
67 AuditEvent::CredentialsIssued {
68 access_key_id,
69 expires_at,
70 } => ComplianceRecord {
71 timestamp: entry.timestamp,
72 session_id: entry.session_id.clone(),
73 provider: entry.provider.clone(),
74 event_type: "credentials_issued".to_string(),
75 identity: access_key_id.get(..8).unwrap_or(access_key_id).to_string(),
78 ttl_seconds: None,
79 allowed_actions: None,
80 command: None,
81 status: Some(format!("expires: {}", expires_at.to_rfc3339())),
82 duration_seconds: None,
83 exit_code: None,
84 },
85 AuditEvent::SessionEnded {
86 status,
87 duration_seconds,
88 exit_code,
89 } => ComplianceRecord {
90 timestamp: entry.timestamp,
91 session_id: entry.session_id.clone(),
92 provider: entry.provider.clone(),
93 event_type: "session_ended".to_string(),
94 identity: String::new(),
95 ttl_seconds: None,
96 allowed_actions: None,
97 command: None,
98 status: Some(status.clone()),
99 duration_seconds: Some(*duration_seconds),
100 exit_code: *exit_code,
101 },
102 AuditEvent::BudgetWarning {
103 current_spend,
104 limit,
105 } => ComplianceRecord {
106 timestamp: entry.timestamp,
107 session_id: entry.session_id.clone(),
108 provider: entry.provider.clone(),
109 event_type: "budget_warning".to_string(),
110 identity: String::new(),
111 ttl_seconds: None,
112 allowed_actions: None,
113 command: None,
114 status: Some(format!("${:.2}/${:.2}", current_spend, limit)),
115 duration_seconds: None,
116 exit_code: None,
117 },
118 AuditEvent::BudgetExceeded { final_spend, limit } => ComplianceRecord {
119 timestamp: entry.timestamp,
120 session_id: entry.session_id.clone(),
121 provider: entry.provider.clone(),
122 event_type: "budget_exceeded".to_string(),
123 identity: String::new(),
124 ttl_seconds: None,
125 allowed_actions: None,
126 command: None,
127 status: Some(format!("${:.2}/${:.2}", final_spend, limit)),
128 duration_seconds: None,
129 exit_code: None,
130 },
131 AuditEvent::ResourceCreated {
132 service,
133 resource_type,
134 identifier,
135 } => ComplianceRecord {
136 timestamp: entry.timestamp,
137 session_id: entry.session_id.clone(),
138 provider: entry.provider.clone(),
139 event_type: "resource_created".to_string(),
140 identity: format!("{service}:{resource_type}"),
141 ttl_seconds: None,
142 allowed_actions: None,
143 command: None,
144 status: Some(identifier.clone()),
145 duration_seconds: None,
146 exit_code: None,
147 },
148 AuditEvent::PolicyAdvisoryOnly { reason } => ComplianceRecord {
149 timestamp: entry.timestamp,
150 session_id: entry.session_id.clone(),
151 provider: entry.provider.clone(),
152 event_type: "policy_advisory_only".to_string(),
153 identity: String::new(),
154 ttl_seconds: None,
155 allowed_actions: None,
156 command: None,
157 status: Some(reason.clone()),
158 duration_seconds: None,
159 exit_code: None,
160 },
161 }
162}
163
164pub fn generate(audit: &AuditLog, days: u32) -> Result<ComplianceReport> {
166 let now = Utc::now();
167 let cutoff = now - chrono::Duration::days(days as i64);
168
169 let entries = audit.read(None)?;
170 let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.timestamp >= cutoff).collect();
171
172 let records: Vec<ComplianceRecord> = filtered.iter().map(|e| flatten(e)).collect();
173
174 let mut providers: Vec<String> = filtered
175 .iter()
176 .map(|e| e.provider.clone())
177 .collect::<std::collections::HashSet<_>>()
178 .into_iter()
179 .collect();
180 providers.sort();
181
182 let total_sessions = records
183 .iter()
184 .filter(|r| r.event_type == "session_created")
185 .count();
186 let failed_sessions = records
187 .iter()
188 .filter(|r| r.event_type == "session_ended" && r.status.as_deref() == Some("failed"))
189 .count();
190
191 Ok(ComplianceReport {
192 generated_at: now,
193 period_start: cutoff,
194 period_end: now,
195 total_records: records.len(),
196 total_sessions,
197 failed_sessions,
198 providers_used: providers,
199 records,
200 })
201}
202
203pub fn export_json(report: &ComplianceReport) -> Result<String> {
205 serde_json::to_string_pretty(report).map_err(|e| {
206 crate::error::AvError::InvalidPolicy(format!("Failed to serialize report: {}", e))
207 })
208}
209
210pub fn export_csv(report: &ComplianceReport) -> String {
212 let mut out = String::new();
213 out.push('\u{FEFF}');
216 out.push_str("timestamp,session_id,provider,event_type,identity,ttl_seconds,allowed_actions,command,status,duration_seconds,exit_code\n");
217
218 for r in &report.records {
219 out.push_str(&format!(
220 "{},{},{},{},{},{},{},{},{},{},{}\n",
221 r.timestamp.to_rfc3339(),
222 csv_escape(&r.session_id),
223 csv_escape(&r.provider),
224 csv_escape(&r.event_type),
225 csv_escape(&r.identity),
226 r.ttl_seconds.map_or(String::new(), |v| v.to_string()),
227 r.allowed_actions
228 .as_deref()
229 .map_or(String::new(), csv_escape),
230 r.command.as_deref().map_or(String::new(), csv_escape),
231 r.status.as_deref().map_or(String::new(), csv_escape),
232 r.duration_seconds.map_or(String::new(), |v| v.to_string()),
233 r.exit_code.map_or(String::new(), |v| v.to_string()),
234 ));
235 }
236
237 out
238}
239
240fn csv_escape(s: &str) -> String {
241 let sanitized = if s.starts_with('=')
245 || s.starts_with('+')
246 || s.starts_with('-')
247 || s.starts_with('@')
248 || s.starts_with('\t')
249 || s.starts_with('\r')
250 {
251 format!("'{}", s)
252 } else {
253 s.to_string()
254 };
255
256 let sanitized = sanitized.replace(['\r', '\n'], " ");
260 if sanitized.contains(',') || sanitized.contains('"') {
261 format!("\"{}\"", sanitized.replace('"', "\"\""))
262 } else {
263 sanitized
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::audit::{AuditEntry, AuditEvent};
271
272 fn sample_entries() -> Vec<AuditEntry> {
273 vec![
274 AuditEntry {
275 timestamp: Utc::now(),
276 session_id: "sess-001".to_string(),
277 provider: "aws".to_string(),
278 event: AuditEvent::SessionCreated {
279 role_arn: "arn:aws:iam::123:role/TestRole".to_string(),
280 ttl_seconds: 900,
281 budget: None,
282 allowed_actions: vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()],
283 command: vec!["aws".to_string(), "s3".to_string(), "ls".to_string()],
284 agent_id: None,
285 },
286 },
287 AuditEntry {
288 timestamp: Utc::now(),
289 session_id: "sess-001".to_string(),
290 provider: "aws".to_string(),
291 event: AuditEvent::SessionEnded {
292 status: "completed".to_string(),
293 duration_seconds: 45,
294 exit_code: Some(0),
295 },
296 },
297 ]
298 }
299
300 #[test]
301 fn test_flatten_session_created() {
302 let entry = &sample_entries()[0];
303 let record = flatten(entry);
304 assert_eq!(record.event_type, "session_created");
305 assert_eq!(record.identity, "arn:aws:iam::123:role/TestRole");
306 assert_eq!(record.ttl_seconds, Some(900));
307 assert!(record.allowed_actions.unwrap().contains("s3:GetObject"));
308 }
309
310 #[test]
311 fn test_flatten_session_ended() {
312 let entry = &sample_entries()[1];
313 let record = flatten(entry);
314 assert_eq!(record.event_type, "session_ended");
315 assert_eq!(record.status, Some("completed".to_string()));
316 assert_eq!(record.duration_seconds, Some(45));
317 assert_eq!(record.exit_code, Some(0));
318 }
319
320 #[test]
321 fn test_csv_escape() {
322 assert_eq!(csv_escape("hello"), "hello");
323 assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
324 assert_eq!(csv_escape("he said \"hi\""), "\"he said \"\"hi\"\"\"");
325 }
326
327 #[test]
328 fn test_compliance_report_serialize() {
329 let report = ComplianceReport {
330 generated_at: Utc::now(),
331 period_start: Utc::now() - chrono::Duration::days(90),
332 period_end: Utc::now(),
333 total_records: 2,
334 total_sessions: 1,
335 failed_sessions: 0,
336 providers_used: vec!["aws".to_string()],
337 records: sample_entries().iter().map(flatten).collect(),
338 };
339
340 let json = export_json(&report).unwrap();
341 assert!(json.contains("total_sessions"));
342 assert!(json.contains("sess-001"));
343
344 let csv = export_csv(&report);
345 let header_start = csv.strip_prefix('\u{FEFF}').unwrap_or(&csv);
349 assert!(header_start.starts_with("timestamp,"));
350 assert!(csv.contains("session_created"));
351 assert!(csv.contains("session_ended"));
352 }
353}