1use 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#[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 #[arg(long, default_value = ".")]
42 pub repo: PathBuf,
43
44 #[arg(long)]
46 pub since: Option<String>,
47
48 #[arg(long)]
50 pub until: Option<String>,
51
52 #[arg(long, value_enum, default_value = "table")]
54 pub format: OutputFormat,
55
56 #[arg(long)]
58 pub require_all_signed: bool,
59
60 #[arg(long)]
62 pub exit_code: bool,
63
64 #[arg(long)]
66 pub author: Option<String>,
67
68 #[arg(long)]
70 pub signer: Option<String>,
71
72 #[arg(long, short = 'n', default_value = "100")]
74 pub count: usize,
75
76 #[arg(long, short = 'o')]
78 pub output_file: Option<PathBuf>,
79}
80
81#[derive(Debug, Clone, Copy, Default, ValueEnum)]
83pub enum OutputFormat {
84 #[default]
86 Table,
87 Csv,
89 Json,
91 Html,
93}
94
95#[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#[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#[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
191fn 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
238fn 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
333fn 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}