Skip to main content

tryaudex_core/
compliance.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::audit::{AuditEntry, AuditEvent, AuditLog};
5use crate::error::Result;
6
7/// Compliance report format.
8#[derive(Debug, Clone, Copy)]
9pub enum ReportFormat {
10    /// JSON report with full audit detail
11    Json,
12    /// CSV for spreadsheet import
13    Csv,
14}
15
16/// A flattened audit record suitable for compliance reporting.
17#[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/// Compliance report wrapper.
33#[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
45/// Flatten an audit entry into a compliance record.
46fn 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            // Truncate access key ID to first 8 chars — compliance CSV is
76            // designed for sharing with auditors; full key IDs are unnecessary.
77            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
164/// Generate a compliance report from audit logs for a given time period.
165pub 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
203/// Export report as JSON.
204pub 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
210/// Export report as CSV.
211pub fn export_csv(report: &ComplianceReport) -> String {
212    let mut out = String::new();
213    // UTF-8 BOM so Excel on non-English Windows interprets the file as UTF-8
214    // instead of the system's default ANSI code page.
215    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    // Prevent CSV formula injection: prefix cells starting with formula
242    // trigger characters with a single quote so spreadsheet software
243    // treats them as text rather than executable formulas.
244    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    // Strip \r and \n to prevent CSV row injection. Newlines inside quoted
257    // fields are technically valid per RFC 4180 but many parsers (Excel,
258    // Google Sheets) treat them as row boundaries, enabling injection.
259    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        // export_csv prepends a UTF-8 BOM (R4-L13) so Excel on non-English
346        // Windows interprets the file as UTF-8. Strip it before the header
347        // check rather than asserting on the raw byte stream.
348        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}