1use anyhow::Result;
2use clap::Args;
3use console::style;
4use dialoguer::Confirm;
5
6use crate::config::Manifest;
7use crate::engine::{audit_log::AuditLog, executor, planner, verifier};
8use crate::github::Client;
9
10#[derive(Args)]
11pub struct SecurityCommand {
12 #[command(subcommand)]
13 action: SecurityAction,
14}
15
16#[derive(clap::Subcommand)]
17enum SecurityAction {
18 Plan,
20
21 Apply {
23 #[arg(long)]
25 yes: bool,
26
27 #[arg(long)]
29 skip_verify: bool,
30 },
31
32 Audit,
34}
35
36impl SecurityCommand {
37 pub async fn run(
38 &self,
39 client: &Client,
40 manifest: &Manifest,
41 system: Option<&str>,
42 repo: Option<&str>,
43 ) -> Result<()> {
44 match &self.action {
45 SecurityAction::Plan => plan(client, manifest, system, repo).await,
46 SecurityAction::Apply { yes, skip_verify } => {
47 apply(client, manifest, system, repo, *yes, *skip_verify).await
48 }
49 SecurityAction::Audit => audit(client, manifest, system, repo).await,
50 }
51 }
52}
53
54async fn resolve_repos(
55 client: &Client,
56 manifest: &Manifest,
57 system: Option<&str>,
58 repo: Option<&str>,
59) -> Result<Vec<String>> {
60 if let Some(repo_name) = repo {
61 return Ok(vec![repo_name.to_owned()]);
62 }
63
64 let sys = system.ok_or_else(|| {
65 anyhow::anyhow!("Either --system or --repo is required for security commands")
66 })?;
67
68 let excludes = manifest.exclude_patterns_for_system(sys);
69 let explicit = manifest.explicit_repos_for_system(sys);
70 let repos = client
71 .list_repos_for_system(sys, &excludes, &explicit)
72 .await?;
73 Ok(repos.into_iter().map(|r| r.name).collect())
74}
75
76async fn build_plans(
77 client: &Client,
78 manifest: &Manifest,
79 system: Option<&str>,
80 repo: Option<&str>,
81) -> Result<(Vec<planner::RepoPlan>, String)> {
82 let repo_names = resolve_repos(client, manifest, system, repo).await?;
83 let sys_id = system.unwrap_or("default");
84 let desired = manifest.security_for_system(sys_id);
85
86 println!();
87 println!(
88 " {} Scanning {} repositories...",
89 style("🔍").bold(),
90 repo_names.len()
91 );
92
93 let mut plans = Vec::new();
94 for repo_name in &repo_names {
95 let current = client.get_security_state(repo_name).await?;
96 let plan = planner::plan_security(repo_name, ¤t, desired);
97 plans.push(plan);
98 }
99
100 Ok((plans, sys_id.to_owned()))
101}
102
103async fn plan(
104 client: &Client,
105 manifest: &Manifest,
106 system: Option<&str>,
107 repo: Option<&str>,
108) -> Result<()> {
109 let (plans, sys_id) = build_plans(client, manifest, system, repo).await?;
110
111 print_plan_table(&plans, &sys_id);
112
113 let needs_changes = plans.iter().filter(|p| p.has_changes()).count();
114 if needs_changes > 0 {
115 println!(
116 "\n Run {} to apply these changes.",
117 style("ward security apply").cyan().bold()
118 );
119 }
120
121 Ok(())
122}
123
124async fn apply(
125 client: &Client,
126 manifest: &Manifest,
127 system: Option<&str>,
128 repo: Option<&str>,
129 yes: bool,
130 skip_verify: bool,
131) -> Result<()> {
132 let (plans, sys_id) = build_plans(client, manifest, system, repo).await?;
133
134 let needs_changes = plans.iter().filter(|p| p.has_changes()).count();
135 if needs_changes == 0 {
136 println!(
137 "\n {} All repositories are up to date.",
138 style("✅").green()
139 );
140 return Ok(());
141 }
142
143 print_plan_table(&plans, &sys_id);
144
145 if !yes {
146 let proceed = Confirm::new()
147 .with_prompt(format!(" Apply changes to {needs_changes} repositories?"))
148 .default(false)
149 .interact()?;
150
151 if !proceed {
152 println!(" Aborted.");
153 return Ok(());
154 }
155 }
156
157 println!();
158 println!(" {} Applying changes...", style("⚡").bold());
159
160 let audit_log = AuditLog::new()?;
161 let report = executor::execute_security_plan(client, &plans, &audit_log).await?;
162 report.print_summary();
163
164 if !skip_verify && report.failed.is_empty() {
165 println!();
166 println!(" {} Verifying changes...", style("🔍").bold());
167
168 let desired = manifest.security_for_system(&sys_id);
169 let verify_report = verifier::verify_security(client, &plans, desired).await?;
170 verify_report.print_summary();
171 }
172
173 println!(
174 "\n {} Audit log: {}",
175 style("📋").bold(),
176 audit_log.path().display()
177 );
178
179 Ok(())
180}
181
182async fn audit(
183 client: &Client,
184 manifest: &Manifest,
185 system: Option<&str>,
186 repo: Option<&str>,
187) -> Result<()> {
188 let repo_names = resolve_repos(client, manifest, system, repo).await?;
189
190 println!();
191 println!(
192 " {} Auditing {} repositories...",
193 style("🔍").bold(),
194 repo_names.len()
195 );
196
197 println!();
198 println!(
199 " {:40} {:8} {:8} {:8} {:8} {:8}",
200 style("Repository").bold().underlined(),
201 style("Dep.A").bold().underlined(),
202 style("Dep.SU").bold().underlined(),
203 style("Secret").bold().underlined(),
204 style("AI").bold().underlined(),
205 style("Push").bold().underlined(),
206 );
207
208 let mut total_ok = 0;
209 let mut total_issues = 0;
210
211 for repo_name in &repo_names {
212 let state = client.get_security_state(repo_name).await?;
213
214 let features = [
215 state.dependabot_alerts,
216 state.dependabot_security_updates,
217 state.secret_scanning,
218 state.secret_scanning_ai_detection,
219 state.push_protection,
220 ];
221
222 let all_ok = features.iter().all(|&f| f);
223 if all_ok {
224 total_ok += 1;
225 } else {
226 total_issues += 1;
227 }
228
229 let icons: Vec<String> = features
230 .iter()
231 .map(|&f| {
232 if f {
233 format!("{}", style("✅").green())
234 } else {
235 format!("{}", style("❌").red())
236 }
237 })
238 .collect();
239
240 println!(
241 " {:40} {:8} {:8} {:8} {:8} {:8}",
242 repo_name, icons[0], icons[1], icons[2], icons[3], icons[4]
243 );
244 }
245
246 println!();
247 println!(
248 " Summary: {} fully secured, {} need attention",
249 style(total_ok).green().bold(),
250 if total_issues > 0 {
251 style(total_issues).red().bold()
252 } else {
253 style(total_issues).green().bold()
254 }
255 );
256
257 Ok(())
258}
259
260fn print_plan_table(plans: &[planner::RepoPlan], system_id: &str) {
261 println!();
262 println!(
263 " {}",
264 style(format!("Security Plan: {system_id}")).bold().cyan()
265 );
266 println!(" {}", style("─".repeat(60)).dim());
267
268 for plan in plans {
269 if plan.has_changes() {
270 println!(" {} {}", style("⚡").yellow(), style(&plan.repo).bold());
271 for change in &plan.changes {
272 let current = if change.current {
273 style("on").green()
274 } else {
275 style("off").red()
276 };
277 let desired = if change.desired {
278 style("on").green().bold()
279 } else {
280 style("off").red().bold()
281 };
282 println!(" {}: {current} → {desired}", change.feature);
283 }
284 } else {
285 println!(" {} {}", style("✓").green(), style(&plan.repo).dim());
286 }
287 }
288
289 let needs_changes = plans.iter().filter(|p| p.has_changes()).count();
290 let up_to_date = plans.len() - needs_changes;
291
292 println!();
293 println!(
294 " Summary: {} need changes, {} up to date",
295 if needs_changes > 0 {
296 style(needs_changes).yellow().bold()
297 } else {
298 style(needs_changes).green().bold()
299 },
300 style(up_to_date).green()
301 );
302}