Skip to main content

auths_cli/commands/
audit.rs

1//! Audit and compliance reporting commands.
2//!
3//! Thin CLI wrapper that wires `Git2LogProvider` into `AuditWorkflow`
4//! and formats the output.
5
6use crate::ux::format::Output;
7use anyhow::{Context, Result, anyhow};
8use auths_infra_git::audit::Git2LogProvider;
9use auths_sdk::ports::git::{CommitRecord, SignatureStatus};
10use auths_sdk::presentation::html::render_audit_html;
11use auths_sdk::workflows::audit::{AuditSummary, AuditWorkflow, summarize_commits};
12use clap::{Parser, ValueEnum};
13use serde::{Deserialize, Serialize};
14use std::path::PathBuf;
15
16/// Audit and compliance reporting.
17#[derive(Parser, Debug, Clone)]
18#[command(
19    name = "audit",
20    about = "Generate signing audit reports for compliance"
21)]
22pub struct AuditCommand {
23    /// Path to the Git repository to audit (defaults to current directory).
24    #[arg(long, default_value = ".")]
25    pub repo: PathBuf,
26
27    /// Start date for audit period (YYYY-MM-DD or YYYY-QN for quarter).
28    #[arg(long)]
29    pub since: Option<String>,
30
31    /// End date for audit period (YYYY-MM-DD).
32    #[arg(long)]
33    pub until: Option<String>,
34
35    /// Output format.
36    #[arg(long, value_enum, default_value = "table")]
37    pub format: OutputFormat,
38
39    /// Require all commits to be signed (for CI exit codes).
40    #[arg(long)]
41    pub require_all_signed: bool,
42
43    /// Return exit code 1 if any unsigned commits found.
44    #[arg(long)]
45    pub exit_code: bool,
46
47    /// Filter by author email.
48    #[arg(long)]
49    pub author: Option<String>,
50
51    /// Filter by signing identity/device DID.
52    #[arg(long)]
53    pub signer: Option<String>,
54
55    /// Maximum number of commits to include.
56    #[arg(long, short = 'n', default_value = "100")]
57    pub count: usize,
58
59    /// Output file path (defaults to stdout).
60    #[arg(long, short = 'o')]
61    pub output_file: Option<PathBuf>,
62}
63
64/// Output format for audit reports.
65#[derive(Debug, Clone, Copy, Default, ValueEnum)]
66pub enum OutputFormat {
67    /// ASCII table format.
68    #[default]
69    Table,
70    /// CSV format.
71    Csv,
72    /// JSON format.
73    Json,
74    /// HTML report.
75    Html,
76}
77
78/// A single commit audit entry (CLI presentation type).
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct CommitAuditEntry {
81    pub hash: String,
82    pub timestamp: String,
83    pub author_name: String,
84    pub author_email: String,
85    pub message: String,
86    pub signing_method: String,
87    pub signer: Option<String>,
88    pub verified: bool,
89}
90
91/// Full audit report (CLI presentation type).
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct AuditReport {
94    pub generated_at: String,
95    pub repository: String,
96    pub period_start: Option<String>,
97    pub period_end: Option<String>,
98    pub summary: AuditSummary,
99    pub commits: Vec<CommitAuditEntry>,
100}
101
102/// Handle the audit command.
103pub fn handle_audit(cmd: AuditCommand) -> Result<()> {
104    let out = Output::new();
105
106    let since = cmd.since.as_ref().map(|s| parse_date_arg(s)).transpose()?;
107    let until = cmd.until.clone();
108
109    let provider = Git2LogProvider::open(&cmd.repo)
110        .with_context(|| format!("Failed to open repository at {:?}", cmd.repo))?;
111    let workflow = AuditWorkflow::new(&provider);
112    let sdk_report = workflow
113        .generate_report(None, Some(cmd.count))
114        .context("Failed to generate audit report")?;
115
116    let mut commits: Vec<CommitRecord> = sdk_report.commits;
117
118    if let Some(author_filter) = &cmd.author {
119        commits.retain(|c| c.author_email.contains(author_filter.as_str()));
120    }
121    if let Some(signer_filter) = &cmd.signer {
122        commits.retain(|c| {
123            matches!(&c.signature_status, SignatureStatus::AuthsSigned { signer_did } if signer_did.contains(signer_filter.as_str()))
124        });
125    }
126
127    let summary = summarize_commits(&commits);
128    let unsigned_commits = summary.unsigned_commits;
129    let generated_at = chrono::Utc::now().to_rfc3339();
130    let repository = cmd.repo.display().to_string();
131
132    let output = match cmd.format {
133        OutputFormat::Html => render_audit_html(&generated_at, &repository, &summary, &commits),
134        _ => {
135            let entries: Vec<CommitAuditEntry> =
136                commits.iter().map(commit_record_to_entry).collect();
137            let report = AuditReport {
138                generated_at,
139                repository,
140                period_start: since,
141                period_end: until,
142                summary,
143                commits: entries,
144            };
145            match cmd.format {
146                OutputFormat::Table => format_as_table(&report),
147                OutputFormat::Csv => format_as_csv(&report),
148                OutputFormat::Json => serde_json::to_string_pretty(&report)?,
149                OutputFormat::Html => unreachable!(),
150            }
151        }
152    };
153
154    if let Some(output_path) = &cmd.output_file {
155        std::fs::write(output_path, &output)
156            .with_context(|| format!("Failed to write report to {:?}", output_path))?;
157        out.print_success(&format!("Report saved to {}", output_path.display()));
158    } else {
159        println!("{}", output);
160    }
161
162    if (cmd.exit_code || cmd.require_all_signed) && unsigned_commits > 0 {
163        if cmd.require_all_signed {
164            out.print_error(&format!("{} unsigned commits found", unsigned_commits));
165        }
166        std::process::exit(1);
167    }
168
169    Ok(())
170}
171
172/// Parse date argument, handling quarter format (YYYY-QN).
173fn parse_date_arg(arg: &str) -> Result<String> {
174    if let Some(caps) = arg
175        .strip_suffix("-Q1")
176        .or_else(|| arg.strip_suffix("-Q2"))
177        .or_else(|| arg.strip_suffix("-Q3"))
178        .or_else(|| arg.strip_suffix("-Q4"))
179    {
180        let year = caps;
181        let quarter = &arg[arg.len() - 2..];
182        let month = match quarter {
183            "Q1" => "01-01",
184            "Q2" => "04-01",
185            "Q3" => "07-01",
186            "Q4" => "10-01",
187            _ => return Err(anyhow!("Invalid quarter format")),
188        };
189        return Ok(format!("{}-{}", year, month));
190    }
191
192    Ok(arg.to_string())
193}
194
195fn commit_record_to_entry(c: &CommitRecord) -> CommitAuditEntry {
196    let (signing_method, verified, signer) = match &c.signature_status {
197        SignatureStatus::AuthsSigned { signer_did } => {
198            ("auths".to_string(), true, Some(signer_did.clone()))
199        }
200        SignatureStatus::SshSigned => ("ssh".to_string(), false, None),
201        SignatureStatus::GpgSigned { verified } => ("gpg".to_string(), *verified, None),
202        SignatureStatus::Unsigned => ("none".to_string(), false, None),
203        SignatureStatus::InvalidSignature { reason } => {
204            ("invalid".to_string(), false, Some(reason.clone()))
205        }
206    };
207    CommitAuditEntry {
208        hash: c.hash.clone(),
209        timestamp: c.timestamp.clone(),
210        author_name: c.author_name.clone(),
211        author_email: c.author_email.clone(),
212        message: c.message.clone(),
213        signing_method,
214        signer,
215        verified,
216    }
217}
218
219/// Format report as ASCII table.
220fn format_as_table(report: &AuditReport) -> String {
221    let mut output = String::new();
222
223    output.push_str("Audit Report\n");
224    output.push_str(&format!("Generated: {}\n", report.generated_at));
225    output.push_str(&format!("Repository: {}\n", report.repository));
226    if let Some(start) = &report.period_start {
227        output.push_str(&format!(
228            "Period: {} to {}\n",
229            start,
230            report.period_end.as_deref().unwrap_or("now")
231        ));
232    }
233    output.push('\n');
234
235    output.push_str("Summary\n");
236    output.push_str("-------\n");
237    output.push_str(&format!(
238        "Total commits:      {:>5}\n",
239        report.summary.total_commits
240    ));
241    output.push_str(&format!(
242        "Signed commits:     {:>5} ({:.0}%)\n",
243        report.summary.signed_commits,
244        if report.summary.total_commits > 0 {
245            (report.summary.signed_commits as f64 / report.summary.total_commits as f64) * 100.0
246        } else {
247            0.0
248        }
249    ));
250    output.push_str(&format!(
251        "Unsigned commits:   {:>5} ({:.0}%)\n",
252        report.summary.unsigned_commits,
253        if report.summary.total_commits > 0 {
254            (report.summary.unsigned_commits as f64 / report.summary.total_commits as f64) * 100.0
255        } else {
256            0.0
257        }
258    ));
259    output.push_str(&format!(
260        "  - GPG signed:     {:>5}\n",
261        report.summary.gpg_signed
262    ));
263    output.push_str(&format!(
264        "  - SSH signed:     {:>5}\n",
265        report.summary.ssh_signed
266    ));
267    output.push_str(&format!(
268        "  - Auths signed:   {:>5}\n",
269        report.summary.auths_signed
270    ));
271    output.push_str(&format!(
272        "Verification passed:{:>5}\n",
273        report.summary.verification_passed
274    ));
275    output.push('\n');
276
277    output.push_str("Commits\n");
278    output.push_str("-------\n");
279    output.push_str(&format!(
280        "{:<10} {:<20} {:<25} {:<8} {:<8}\n",
281        "Hash", "Date", "Author", "Method", "Verified"
282    ));
283    output.push_str(&"-".repeat(80));
284    output.push('\n');
285
286    for commit in &report.commits {
287        let date = if commit.timestamp.len() >= 10 {
288            &commit.timestamp[..10]
289        } else {
290            &commit.timestamp
291        };
292        let author = if commit.author_name.len() > 23 {
293            format!("{}...", &commit.author_name[..20])
294        } else {
295            commit.author_name.clone()
296        };
297        let verified = if commit.signing_method == "none" {
298            "-"
299        } else if commit.verified {
300            "yes"
301        } else {
302            "no"
303        };
304
305        output.push_str(&format!(
306            "{:<10} {:<20} {:<25} {:<8} {:<8}\n",
307            commit.hash, date, author, commit.signing_method, verified
308        ));
309    }
310
311    output
312}
313
314/// Format report as CSV.
315fn format_as_csv(report: &AuditReport) -> String {
316    let mut output = String::new();
317
318    output.push_str(
319        "hash,timestamp,author_name,author_email,message,signing_method,signer,verified\n",
320    );
321
322    for commit in &report.commits {
323        output.push_str(&format!(
324            "{},{},\"{}\",{},\"{}\",{},{},{}\n",
325            commit.hash,
326            commit.timestamp,
327            commit.author_name.replace('"', "\"\""),
328            commit.author_email,
329            commit.message.replace('"', "\"\""),
330            commit.signing_method,
331            commit.signer.as_deref().unwrap_or(""),
332            commit.verified
333        ));
334    }
335
336    output
337}
338
339use crate::commands::executable::ExecutableCommand;
340use crate::config::CliConfig;
341
342impl ExecutableCommand for AuditCommand {
343    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
344        handle_audit(self.clone())
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_parse_date_arg_quarter() {
354        assert_eq!(parse_date_arg("2024-Q1").unwrap(), "2024-01-01");
355        assert_eq!(parse_date_arg("2024-Q2").unwrap(), "2024-04-01");
356        assert_eq!(parse_date_arg("2024-Q3").unwrap(), "2024-07-01");
357        assert_eq!(parse_date_arg("2024-Q4").unwrap(), "2024-10-01");
358    }
359
360    #[test]
361    fn test_parse_date_arg_date() {
362        assert_eq!(parse_date_arg("2024-01-15").unwrap(), "2024-01-15");
363    }
364
365    #[test]
366    fn test_summarize_commits() {
367        use auths_sdk::ports::git::{CommitRecord, SignatureStatus};
368        let commits = vec![
369            CommitRecord {
370                hash: "abc123".to_string(),
371                timestamp: "2024-01-15T10:00:00Z".to_string(),
372                author_name: "Test".to_string(),
373                author_email: "test@example.com".to_string(),
374                message: "test".to_string(),
375                signature_status: SignatureStatus::GpgSigned { verified: true },
376            },
377            CommitRecord {
378                hash: "def456".to_string(),
379                timestamp: "2024-01-16T10:00:00Z".to_string(),
380                author_name: "Test".to_string(),
381                author_email: "test@example.com".to_string(),
382                message: "test".to_string(),
383                signature_status: SignatureStatus::Unsigned,
384            },
385        ];
386
387        let summary = summarize_commits(&commits);
388        assert_eq!(summary.total_commits, 2);
389        assert_eq!(summary.signed_commits, 1);
390        assert_eq!(summary.unsigned_commits, 1);
391        assert_eq!(summary.gpg_signed, 1);
392        assert_eq!(summary.verification_passed, 1);
393    }
394}