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)]
22pub struct AuditCommand {
23 #[arg(long, default_value = ".")]
25 pub repo: PathBuf,
26
27 #[arg(long)]
29 pub since: Option<String>,
30
31 #[arg(long)]
33 pub until: Option<String>,
34
35 #[arg(long, value_enum, default_value = "table")]
37 pub format: OutputFormat,
38
39 #[arg(long)]
41 pub require_all_signed: bool,
42
43 #[arg(long)]
45 pub exit_code: bool,
46
47 #[arg(long)]
49 pub author: Option<String>,
50
51 #[arg(long)]
53 pub signer: Option<String>,
54
55 #[arg(long, short = 'n', default_value = "100")]
57 pub count: usize,
58
59 #[arg(long, short = 'o')]
61 pub output_file: Option<PathBuf>,
62}
63
64#[derive(Debug, Clone, Copy, Default, ValueEnum)]
66pub enum OutputFormat {
67 #[default]
69 Table,
70 Csv,
72 Json,
74 Html,
76}
77
78#[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#[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
102pub 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
172fn 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
219fn 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
314fn 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}