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