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; fn validate_file_path(file_path: &str) -> Result<PathBuf> {
20 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 if !path.exists() {
31 return Err(anyhow::anyhow!("File not found: {}", clean_path));
32 }
33
34 let canonical_path = path
36 .canonicalize()
37 .map_err(|e| anyhow::anyhow!("Invalid file path '{}': {}", clean_path, e))?;
38
39 let current_dir = std::env::current_dir()
41 .map_err(|e| anyhow::anyhow!("Cannot determine current directory: {}", e))?;
42
43 if !canonical_path.starts_with(¤t_dir) {
45 return Err(anyhow::anyhow!(
46 "Access denied: file '{}' is outside the current working directory",
47 clean_path
48 ));
49 }
50
51 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 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>, },
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 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 let base_url = self.config_store.get_base_url()?;
525
526 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 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 let mut request_body = json!({
558 "file_path": validated_path.to_string_lossy(),
559 "question": message,
560 "provider": "http", "inference_url": provider.base_url,
562 });
563
564 if let Some(api_key) = &provider.api_key {
566 request_body["provider_token"] = json!(api_key);
567 }
568
569 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 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}