nabla_cli/cli/
mod.rs

1use anyhow::Result;
2use clap::Subcommand;
3use reqwest::{Client, multipart};
4use serde_json::json;
5use sha2::{Digest, Sha256};
6use std::path::{Path, PathBuf};
7
8mod auth;
9mod config;
10mod jwt_store;
11
12use crate::ssrf_protection::SSRFValidator;
13pub use auth::AuthArgs;
14pub use config::{ConfigCommands, ConfigStore, LLMProvider, LLMProvidersConfig};
15pub use jwt_store::*;
16
17const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024; // 100MB limit
18
19fn validate_file_path(file_path: &str) -> Result<PathBuf> {
20    // Remove any @ prefix if present
21    let clean_path = if file_path.starts_with('@') {
22        &file_path[1..]
23    } else {
24        file_path
25    };
26
27    let path = Path::new(clean_path);
28
29    // Check if file exists
30    if !path.exists() {
31        return Err(anyhow::anyhow!("File not found: {}", clean_path));
32    }
33
34    // Get canonical path to resolve any .. or . components
35    let canonical_path = path
36        .canonicalize()
37        .map_err(|e| anyhow::anyhow!("Invalid file path '{}': {}", clean_path, e))?;
38
39    // Get current working directory
40    let current_dir = std::env::current_dir()
41        .map_err(|e| anyhow::anyhow!("Cannot determine current directory: {}", e))?;
42
43    // Ensure the canonical path is within or below the current working directory
44    if !canonical_path.starts_with(&current_dir) {
45        return Err(anyhow::anyhow!(
46            "Access denied: file '{}' is outside the current working directory",
47            clean_path
48        ));
49    }
50
51    // Check file size
52    let metadata = std::fs::metadata(&canonical_path)
53        .map_err(|e| anyhow::anyhow!("Cannot read file metadata: {}", e))?;
54
55    if metadata.len() > MAX_FILE_SIZE {
56        return Err(anyhow::anyhow!(
57            "File too large: {} bytes (max: {} bytes)",
58            metadata.len(),
59            MAX_FILE_SIZE
60        ));
61    }
62
63    // Ensure it's a regular file, not a symlink or directory
64    if !metadata.is_file() {
65        return Err(anyhow::anyhow!(
66            "Path must be a regular file: {}",
67            clean_path
68        ));
69    }
70
71    Ok(canonical_path)
72}
73
74#[derive(Subcommand)]
75pub enum Commands {
76    Auth {
77        #[command(flatten)]
78        args: AuthArgs,
79    },
80    Config {
81        #[command(subcommand)]
82        command: ConfigCommands,
83    },
84    Binary {
85        #[command(subcommand)]
86        command: BinaryCommands,
87    },
88    Diff {
89        file1: String,
90        file2: String,
91    },
92    Chat {
93        file: String,
94        message: String,
95        #[arg(long)]
96        provider: Option<String>, // Optional provider name, uses default if not specified
97    },
98    Upgrade,
99    Server {
100        #[arg(long, default_value = "8080")]
101        port: u16,
102    },
103}
104
105#[derive(Subcommand)]
106pub enum BinaryCommands {
107    Analyze {
108        file: String,
109    },
110    Attest {
111        file: String,
112        #[arg(long)]
113        signing_key: String,
114    },
115    CheckCves {
116        file: String,
117    },
118}
119
120pub struct NablaCli {
121    jwt_store: JwtStore,
122    config_store: ConfigStore,
123    http_client: Client,
124}
125
126impl NablaCli {
127    pub fn new() -> Result<Self> {
128        Ok(Self {
129            jwt_store: JwtStore::new()?,
130            config_store: ConfigStore::new()?,
131            http_client: Client::new(),
132        })
133    }
134
135    pub async fn show_intro_and_help(&self) -> Result<()> {
136        self.print_ascii_intro();
137        self.print_help();
138        Ok(())
139    }
140
141    pub async fn handle_command(&mut self, command: Commands) -> Result<()> {
142        match command {
143            Commands::Auth { args } => self.handle_auth_args(args),
144            Commands::Config { command } => self.handle_config_command(command),
145            Commands::Binary { command } => self.handle_binary_command(command).await,
146            Commands::Diff { file1, file2 } => self.handle_diff_command(&file1, &file2).await,
147            Commands::Chat {
148                file,
149                message,
150                provider,
151            } => {
152                self.handle_chat_command(&file, &message, provider.as_deref())
153                    .await
154            }
155            Commands::Upgrade => self.handle_upgrade_command(),
156            Commands::Server { port } => self.handle_server_command(port).await,
157        }
158    }
159
160    fn handle_config_command(&mut self, command: ConfigCommands) -> Result<()> {
161        match command {
162            ConfigCommands::Get { key } => {
163                let value = self.config_store.get_setting(&key)?;
164                match value {
165                    Some(val) => println!("{}: {}", key, val),
166                    None => println!("No value set for key: {}", key),
167                }
168                Ok(())
169            }
170            ConfigCommands::Set { key, value } => {
171                self.config_store.set_setting(&key, &value)?;
172                println!("Set {} = {}", key, value);
173                Ok(())
174            }
175            ConfigCommands::SetBaseUrl { url } => self.config_store.set_base_url(&url),
176            ConfigCommands::List => {
177                let settings = self.config_store.list_settings()?;
178                if settings.is_empty() {
179                    println!("No configuration settings found.");
180                } else {
181                    println!("Configuration settings:");
182                    for (key, value) in settings {
183                        println!("  {}: {}", key, value);
184                    }
185                }
186                Ok(())
187            }
188            ConfigCommands::AddProvider {
189                name,
190                provider_type,
191                api_key,
192                base_url,
193                model,
194                default,
195            } => {
196                let mut providers_config = LLMProvidersConfig::new()?;
197                let provider = LLMProvider {
198                    name: name.clone(),
199                    provider_type,
200                    api_key,
201                    base_url,
202                    model,
203                    default,
204                };
205                providers_config.add_provider(provider)?;
206                println!("āœ… Added LLM provider: {}", name);
207                Ok(())
208            }
209            ConfigCommands::RemoveProvider { name } => {
210                let mut providers_config = LLMProvidersConfig::new()?;
211                providers_config.remove_provider(&name)?;
212                println!("āœ… Removed LLM provider: {}", name);
213                Ok(())
214            }
215            ConfigCommands::ListProviders => {
216                let providers_config = LLMProvidersConfig::new()?;
217                let providers = providers_config.list_providers();
218                if providers.is_empty() {
219                    println!("No LLM providers configured.");
220                    println!();
221                    println!("šŸ’” Add a provider with:");
222                    println!(
223                        "  nabla config add-provider <name> --provider-type openai --base-url https://api.openai.com --api-key <your-key>"
224                    );
225                } else {
226                    println!("Configured LLM providers:");
227                    for provider in providers {
228                        let default_marker = if provider.default { " (default)" } else { "" };
229                        let api_key_status = if provider.api_key.is_some() {
230                            "āœ…"
231                        } else {
232                            "āŒ"
233                        };
234                        println!(
235                            "  {} {}{} - {} - Key: {}",
236                            provider.name,
237                            provider.provider_type,
238                            default_marker,
239                            provider.base_url,
240                            api_key_status
241                        );
242                    }
243                }
244                Ok(())
245            }
246            ConfigCommands::SetDefaultProvider { name } => {
247                let mut providers_config = LLMProvidersConfig::new()?;
248                providers_config.set_default_provider(&name)?;
249                println!("āœ… Set default LLM provider: {}", name);
250                Ok(())
251            }
252        }
253    }
254
255    fn print_ascii_intro(&self) {
256        println!(
257            r#"
258    ā–ˆā–ˆā–ˆā•—   ā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—      ā–ˆā–ˆā–ˆā–ˆā–ˆā•— 
259    ā–ˆā–ˆā–ˆā–ˆā•—  ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘     ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—
260    ā–ˆā–ˆā•”ā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā•‘     ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•‘
261    ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•—ā–ˆā–ˆā•‘     ā–ˆā–ˆā•”ā•ā•ā–ˆā–ˆā•‘
262    ā–ˆā–ˆā•‘ ā•šā–ˆā–ˆā–ˆā–ˆā•‘ā–ˆā–ˆā•‘  ā–ˆā–ˆā•‘ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘  ā–ˆā–ˆā•‘
263    ā•šā•ā•  ā•šā•ā•ā•ā•ā•šā•ā•  ā•šā•ā•ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā•  ā•šā•ā•
264                                              
265    šŸ”’ Binary Analysis & Security Platform
266        "#
267        );
268    }
269
270    fn print_help(&self) {
271        println!("Available Commands:");
272        println!();
273        println!("šŸ” Authentication:");
274        println!("  nabla auth upgrade      - Upgrade your plan");
275        println!("  nabla auth status       - Check authentication status");
276        println!("  nabla auth --set-jwt <token> - Set JWT token for authentication");
277        println!();
278        println!("āš™ļø  Configuration:");
279        println!("  nabla config get <key>      - Get configuration value");
280        println!("  nabla config set <key> <val> - Set configuration value");
281        println!("  nabla config set-base-url <url> - Set base URL for API requests");
282        println!("  nabla config list           - List all configuration");
283        println!();
284        println!("šŸ” Binary Analysis:");
285        println!("  nabla binary analyze <file>  - Analyze a binary file");
286        println!("  nabla binary attest --signing-key <key> <file> - Create signed attestation");
287        println!("  nabla binary check-cves <file> - Check for CVEs");
288        println!();
289        println!("šŸ” Comparison:");
290        println!("  nabla diff <file1> <file2>   - Compare two binaries");
291        println!();
292        println!("šŸ’¬ Chat (Premium Feature):");
293        println!("  nabla chat <message>         - Chat about analysis");
294        println!();
295        println!("šŸš€ Upgrade:");
296        println!("  nabla upgrade               - Upgrade to AWS Marketplace plan");
297        println!();
298        println!("šŸ–„ļø  Server:");
299        println!("  nabla server --port <port>  - Start HTTP server (default: 8080)");
300        println!();
301        println!("šŸ’” Tip: Run 'nabla upgrade' to unlock premium features!");
302    }
303
304    async fn handle_binary_command(&mut self, command: BinaryCommands) -> Result<()> {
305        match command {
306            BinaryCommands::Analyze { file } => self.handle_analyze_command(&file).await,
307            BinaryCommands::Attest { file, signing_key } => {
308                self.handle_attest_command(&file, signing_key).await
309            }
310            BinaryCommands::CheckCves { file } => self.handle_check_cves_command(&file).await,
311        }
312    }
313
314    async fn handle_analyze_command(&mut self, file_path: &str) -> Result<()> {
315        let validated_path = validate_file_path(file_path)?;
316
317        println!("šŸ” Analyzing binary: {}", validated_path.display());
318
319        let jwt_data = self.jwt_store.load_jwt().ok().flatten();
320        let base_url = self.config_store.get_base_url()?;
321        let url = format!("{}/binary/analyze", base_url);
322
323        let file_content = std::fs::read(&validated_path)?;
324        let file_name = validated_path
325            .file_name()
326            .unwrap_or_default()
327            .to_string_lossy()
328            .to_string();
329
330        println!("šŸ”„ Uploading to analysis endpoint...");
331
332        let ssrf_validator = SSRFValidator::new();
333        let validated_url = ssrf_validator.validate_url(&url)?;
334
335        let part = multipart::Part::bytes(file_content).file_name(file_name);
336        let form = multipart::Form::new().part("file", part);
337
338        let mut request = self.http_client.post(validated_url.to_string());
339        if let Some(jwt) = jwt_data.as_ref() {
340            request = request.bearer_auth(&jwt.token);
341        }
342
343        let response = request.multipart(form).send().await?;
344        let result = response.json::<serde_json::Value>().await?;
345
346        println!("āœ… Analysis complete!");
347        println!("Results: {}", serde_json::to_string_pretty(&result)?);
348
349        Ok(())
350    }
351
352    async fn handle_attest_command(&mut self, file_path: &str, signing_key: String) -> Result<()> {
353        let jwt_data = self.jwt_store.load_jwt()?
354            .ok_or_else(|| anyhow::anyhow!("Authentication required for binary attestation. Run 'nabla auth --set-jwt <token>' or 'nabla upgrade'"))?;
355
356        let validated_file_path = validate_file_path(file_path)?;
357        let validated_key_path = validate_file_path(&signing_key)?;
358
359        println!("šŸ” Attesting binary: {}", validated_file_path.display());
360
361        let base_url = self.config_store.get_base_url()?;
362        let url = format!("{}/binary/attest", base_url);
363
364        let file_content = std::fs::read(&validated_file_path)?;
365        let file_name = validated_file_path
366            .file_name()
367            .unwrap_or_default()
368            .to_string_lossy()
369            .to_string();
370        let signing_key_content = std::fs::read(&validated_key_path)?;
371
372        println!("šŸ”„ Uploading to attestation endpoint...");
373
374        let ssrf_validator = SSRFValidator::new();
375        let validated_url = ssrf_validator.validate_url(&url)?;
376
377        let file_part = multipart::Part::bytes(file_content.clone()).file_name(file_name.clone());
378        let key_part = multipart::Part::bytes(signing_key_content).file_name("key.pem");
379        let form = multipart::Form::new()
380            .part("file", file_part)
381            .part("signing_key", key_part);
382
383        let response = self
384            .http_client
385            .post(validated_url.to_string())
386            .bearer_auth(&jwt_data.token)
387            .multipart(form)
388            .send()
389            .await?;
390        let result = response.json::<serde_json::Value>().await?;
391
392        // Mock attestation using SHA-256
393        let mut hasher = Sha256::new();
394        hasher.update(&file_content);
395        let hash = hasher.finalize();
396        let attestation = json!({
397            "_type": "https://in-toto.io/Statement/v0.1",
398            "subject": [{
399                "name": file_name,
400                "digest": {
401                    "sha256": format!("{:x}", hash)
402                }
403            }],
404            "predicateType": "https://nabla.sh/attestation/v0.1",
405            "predicate": {
406                "timestamp": chrono::Utc::now().to_rfc3339(),
407                "analysis": {
408                    "format": "ELF",
409                    "architecture": "x86_64",
410                    "security_score": 85
411                }
412            }
413        });
414
415        println!("āœ… Attestation complete!");
416        println!(
417            "Results: {}",
418            serde_json::to_string_pretty(&json!({
419                "analysis": result,
420                "attestation": attestation
421            }))?
422        );
423
424        Ok(())
425    }
426
427    async fn handle_check_cves_command(&mut self, file_path: &str) -> Result<()> {
428        let validated_path = validate_file_path(file_path)?;
429        let jwt_data = self.jwt_store.load_jwt().ok().flatten();
430
431        println!("šŸ” Checking CVEs for: {}", validated_path.display());
432
433        let base_url = self.config_store.get_base_url()?;
434        let url = format!("{}/binary/check-cves", base_url);
435
436        let file_content = std::fs::read(&validated_path)?;
437        let file_name = validated_path
438            .file_name()
439            .unwrap_or_default()
440            .to_string_lossy()
441            .to_string();
442
443        println!("šŸ”„ Uploading to CVE check endpoint...");
444
445        let ssrf_validator = SSRFValidator::new();
446        let validated_url = ssrf_validator.validate_url(&url)?;
447
448        let part = multipart::Part::bytes(file_content).file_name(file_name);
449        let form = multipart::Form::new().part("file", part);
450
451        let mut request = self.http_client.post(validated_url.to_string());
452        if let Some(jwt) = jwt_data.as_ref() {
453            request = request.bearer_auth(&jwt.token);
454        }
455
456        let response = request.multipart(form).send().await?;
457        let result = response.json::<serde_json::Value>().await?;
458
459        println!("āœ… CVE check complete!");
460        println!("Results: {}", serde_json::to_string_pretty(&result)?);
461
462        Ok(())
463    }
464
465    async fn handle_diff_command(&mut self, file1: &str, file2: &str) -> Result<()> {
466        let validated_path1 = validate_file_path(file1)?;
467        let validated_path2 = validate_file_path(file2)?;
468        let jwt_data = self.jwt_store.load_jwt().ok().flatten();
469
470        println!(
471            "šŸ” Comparing binaries: {} vs {}",
472            validated_path1.display(),
473            validated_path2.display()
474        );
475
476        let base_url = self.config_store.get_base_url()?;
477        let url = format!("{}/binary/diff", base_url);
478
479        let file1_content = std::fs::read(&validated_path1)?;
480        let file2_content = std::fs::read(&validated_path2)?;
481        let file1_name = validated_path1
482            .file_name()
483            .unwrap_or_default()
484            .to_string_lossy()
485            .to_string();
486        let file2_name = validated_path2
487            .file_name()
488            .unwrap_or_default()
489            .to_string_lossy()
490            .to_string();
491
492        println!("šŸ”„ Uploading files to diff endpoint...");
493
494        let ssrf_validator = SSRFValidator::new();
495        let validated_url = ssrf_validator.validate_url(&url)?;
496
497        let file1_part = multipart::Part::bytes(file1_content).file_name(file1_name);
498        let file2_part = multipart::Part::bytes(file2_content).file_name(file2_name);
499        let form = multipart::Form::new()
500            .part("file1", file1_part)
501            .part("file2", file2_part);
502
503        let mut request = self.http_client.post(validated_url.to_string());
504        if let Some(jwt) = jwt_data.as_ref() {
505            request = request.bearer_auth(&jwt.token);
506        }
507
508        let response = request.multipart(form).send().await?;
509        let result = response.json::<serde_json::Value>().await?;
510
511        println!("āœ… Diff analysis complete!");
512        println!("Results: {}", serde_json::to_string_pretty(&result)?);
513
514        Ok(())
515    }
516
517    async fn handle_chat_command(
518        &mut self,
519        file_path: &str,
520        message: &str,
521        provider_name: Option<&str>,
522    ) -> Result<()> {
523        // For OSS, we don't require JWT authentication - just check if providers are configured
524        let base_url = self.config_store.get_base_url()?;
525
526        // Load LLM provider configuration
527        let providers_config = LLMProvidersConfig::new()?;
528
529        let provider = if let Some(name) = provider_name {
530            providers_config.get_provider(name)
531                .ok_or_else(|| anyhow::anyhow!("Provider '{}' not found. Use 'nabla config list-providers' to see available providers.", name))?
532        } else {
533            providers_config.get_default_provider()
534                .ok_or_else(|| anyhow::anyhow!("No default provider configured. Use 'nabla config add-provider' to add one or specify --provider <name>"))?
535        };
536
537        // Validate and read the file
538        let validated_path = validate_file_path(file_path)?;
539        let file_name = validated_path
540            .file_name()
541            .and_then(|n| n.to_str())
542            .unwrap_or("unknown")
543            .to_string();
544
545        println!("šŸ” Analyzing file: {}", file_name);
546        println!("šŸ’¬ Question: {}", message);
547        println!(
548            "šŸ¤– Using provider: {} ({})",
549            provider.name, provider.provider_type
550        );
551
552        let url = format!("{}/binary/chat", base_url);
553        let ssrf_validator = SSRFValidator::new();
554        let validated_url = ssrf_validator.validate_url(&url)?;
555
556        // Create request payload matching the API format
557        let mut request_body = json!({
558            "file_path": validated_path.to_string_lossy(),
559            "question": message,
560            "provider": "http", // Always use HTTP provider for configured providers
561            "inference_url": provider.base_url,
562        });
563
564        // Add API key if available
565        if let Some(api_key) = &provider.api_key {
566            request_body["provider_token"] = json!(api_key);
567        }
568
569        // Add model if specified
570        if let Some(model) = &provider.model {
571            request_body["model_path"] = json!(model);
572        }
573
574        let mut request = self.http_client.post(validated_url.to_string());
575
576        // Only add JWT auth if we have it (for enterprise features)
577        if let Some(jwt_data) = self.jwt_store.load_jwt()? {
578            request = request.bearer_auth(&jwt_data.token);
579        }
580
581        let response = request.json(&request_body).send().await?;
582
583        if !response.status().is_success() {
584            let error_text = response.text().await?;
585            return Err(anyhow::anyhow!("Chat request failed: {}", error_text));
586        }
587
588        let result = response.json::<serde_json::Value>().await?;
589
590        println!("āœ… Analysis complete!");
591        if let Some(answer) = result.get("answer") {
592            println!(
593                "\nšŸ“ Response:\n{}",
594                answer.as_str().unwrap_or("No response")
595            );
596        }
597        if let Some(model_used) = result.get("model_used") {
598            println!("\nšŸ¤– Model: {}", model_used.as_str().unwrap_or("unknown"));
599        }
600
601        Ok(())
602    }
603
604    fn handle_upgrade_command(&mut self) -> Result<()> {
605        if let Some(_jwt_data) = self.jwt_store.load_jwt()? {
606            println!("āœ… You are already authenticated!");
607            println!();
608            println!("šŸ’” You can use the CLI to analyze binaries:");
609            println!("  nabla binary analyze /path/to/binary");
610            return Ok(());
611        }
612
613        self.show_upgrade_message();
614        Ok(())
615    }
616
617    fn show_upgrade_message(&self) {
618        let scheduling_url = "https://cal.com/team/atelier-logos/platform-intro";
619
620        println!("šŸš€ Ready to upgrade to Nabla Pro?");
621        println!();
622        println!("Let's discuss the perfect plan for your security needs:");
623        println!("  • Binary analysis with AI-powered insights");
624        println!("  • Signed attestation and compliance features");
625        println!("  • Custom deployment and enterprise integrations");
626        println!("  • Dedicated support and training");
627        println!();
628
629        #[cfg(feature = "cloud")]
630        {
631            if let Err(e) = webbrowser::open(scheduling_url) {
632                println!("āŒ Could not open browser automatically: {}", e);
633                println!("Please visit: {}", scheduling_url);
634            } else {
635                println!("🌐 Opening scheduling page in your browser...");
636                println!("šŸ“… Schedule your demo: {}", scheduling_url);
637            }
638        }
639
640        #[cfg(not(feature = "cloud"))]
641        {
642            println!("šŸ“… Schedule your demo: {}", scheduling_url);
643            println!("šŸ’” Copy and paste this link into your browser to get started.");
644        }
645
646        println!();
647        println!("After our call, you'll receive a token to get started:");
648        println!("  nabla auth --set-jwt <YOUR_TOKEN>");
649    }
650
651    async fn handle_server_command(&self, port: u16) -> Result<()> {
652        println!("šŸš€ Starting Nabla server on port {}", port);
653        println!("šŸ“” Server will be available at: http://localhost:{}", port);
654        println!("šŸ” Endpoints:");
655        println!("  POST /binary/analyze   - Binary analysis");
656        println!("  POST /binary/attest    - Binary attestation (Premium)");
657        println!("  POST /binary/check-cves - CVE checking");
658        println!("  POST /binary/diff      - Binary comparison");
659        println!("  POST /binary/chat      - AI chat");
660        println!();
661        println!("šŸ’” Use Ctrl+C to stop the server");
662        println!();
663
664        crate::server::run_server(port).await
665    }
666}