Skip to main content

ward/cli/
security.rs

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    /// Show what security changes would be made (dry-run)
19    Plan,
20
21    /// Apply security changes to repositories
22    Apply {
23        /// Skip confirmation prompt
24        #[arg(long)]
25        yes: bool,
26
27        /// Skip post-apply verification
28        #[arg(long)]
29        skip_verify: bool,
30    },
31
32    /// Audit current security state across all repos
33    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, &current, 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}