1use std::collections::HashMap;
40use std::fs;
41use std::path::{Path, PathBuf};
42use std::time::Instant;
43
44use clap::Args;
45use colored::Colorize;
46use serde_json::Value;
47use tree_sitter::Node;
48
49use crate::output::OutputFormat;
50
51use super::ast_cache::AstCache;
52use super::error::{RemainingError, RemainingResult};
53use super::types::{SecureFinding, SecureReport, SecureSummary};
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SecurityAnalysis {
62 Taint,
63 Resources,
64 Bounds,
65 Contracts,
66 Behavioral,
67 Mutability,
68}
69
70impl SecurityAnalysis {
71 pub fn name(&self) -> &'static str {
73 match self {
74 Self::Taint => "taint",
75 Self::Resources => "resources",
76 Self::Bounds => "bounds",
77 Self::Contracts => "contracts",
78 Self::Behavioral => "behavioral",
79 Self::Mutability => "mutability",
80 }
81 }
82}
83
84pub const QUICK_ANALYSES: &[SecurityAnalysis] = &[
86 SecurityAnalysis::Taint,
87 SecurityAnalysis::Resources,
88 SecurityAnalysis::Bounds,
89];
90
91pub const FULL_ANALYSES: &[SecurityAnalysis] = &[
93 SecurityAnalysis::Taint,
94 SecurityAnalysis::Resources,
95 SecurityAnalysis::Bounds,
96 SecurityAnalysis::Contracts,
97 SecurityAnalysis::Behavioral,
98 SecurityAnalysis::Mutability,
99];
100
101#[derive(Debug, Args, Clone)]
107pub struct SecureArgs {
108 pub path: PathBuf,
110
111 #[arg(long)]
113 pub detail: Option<String>,
114
115 #[arg(long)]
117 pub quick: bool,
118
119 #[arg(long, short = 'o')]
121 pub output: Option<PathBuf>,
122}
123
124impl SecureArgs {
125 pub fn run(&self, format: OutputFormat) -> anyhow::Result<()> {
127 run(self.clone(), format)
128 }
129}
130
131pub fn run(args: SecureArgs, format: OutputFormat) -> anyhow::Result<()> {
137 let start = Instant::now();
138
139 if !args.path.exists() {
141 return Err(RemainingError::file_not_found(&args.path).into());
142 }
143
144 let mut report = SecureReport::new(args.path.display().to_string());
146
147 let mut cache = AstCache::default();
149
150 let analyses = if args.quick {
152 QUICK_ANALYSES
153 } else {
154 FULL_ANALYSES
155 };
156
157 let files = collect_files(&args.path)?;
159
160 let mut all_findings = Vec::new();
162 let mut sub_results: HashMap<String, Value> = HashMap::new();
163
164 for analysis in analyses {
165 let (findings, raw_result) = run_security_analysis(*analysis, &files, &mut cache)?;
166
167 update_summary(&mut report.summary, *analysis, &findings);
169
170 all_findings.extend(findings);
172
173 if args.detail.as_deref() == Some(analysis.name()) {
175 sub_results.insert(analysis.name().to_string(), raw_result);
176 }
177 }
178
179 all_findings.sort_by(|a, b| severity_order(&a.severity).cmp(&severity_order(&b.severity)));
181
182 report.findings = all_findings;
183 report.sub_results = sub_results;
184 report.total_elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
185
186 let output_str = match format {
188 OutputFormat::Json => serde_json::to_string_pretty(&report)?,
189 OutputFormat::Compact => serde_json::to_string(&report)?,
190 OutputFormat::Text => format_text_report(&report),
191 OutputFormat::Sarif | OutputFormat::Dot => {
192 serde_json::to_string_pretty(&report)?
194 }
195 };
196
197 if let Some(output_path) = &args.output {
199 fs::write(output_path, &output_str)?;
200 } else {
201 println!("{}", output_str);
202 }
203
204 Ok(())
205}
206
207fn collect_files(path: &Path) -> RemainingResult<Vec<PathBuf>> {
209 let mut files = Vec::new();
210
211 if path.is_file() {
212 if is_supported_secure_file(path) {
213 files.push(path.to_path_buf());
214 }
215 } else if path.is_dir() {
216 for entry in walkdir::WalkDir::new(path)
218 .max_depth(10)
219 .into_iter()
220 .filter_map(|e| e.ok())
221 {
222 let p = entry.path();
223 if p.is_file() && is_supported_secure_file(p) {
224 files.push(p.to_path_buf());
225 }
226 }
227 }
228
229 Ok(files)
233}
234
235fn is_supported_secure_file(path: &std::path::Path) -> bool {
236 matches!(path.extension().and_then(|e| e.to_str()), Some("py" | "rs"))
237}
238
239fn is_rust_file(path: &std::path::Path) -> bool {
240 matches!(path.extension().and_then(|e| e.to_str()), Some("rs"))
241}
242
243fn is_rust_test_file(path: &std::path::Path) -> bool {
244 let p = path.to_string_lossy();
245 p.contains("/tests/")
246 || p.contains("\\tests\\")
247 || p.ends_with("_test.rs")
248 || p.ends_with("tests.rs")
249}
250
251fn run_security_analysis(
253 analysis: SecurityAnalysis,
254 files: &[PathBuf],
255 cache: &mut AstCache,
256) -> RemainingResult<(Vec<SecureFinding>, Value)> {
257 let mut findings = Vec::new();
258
259 for file in files {
260 let source = fs::read_to_string(file)?;
261
262 let tree = cache.get_or_parse(file, &source)?;
264
265 let file_findings = match analysis {
267 SecurityAnalysis::Taint => analyze_taint(tree.root_node(), &source, file),
268 SecurityAnalysis::Resources => analyze_resources(tree.root_node(), &source, file),
269 SecurityAnalysis::Bounds => analyze_bounds(tree.root_node(), &source, file),
270 SecurityAnalysis::Contracts => analyze_contracts(tree.root_node(), &source, file),
271 SecurityAnalysis::Behavioral => analyze_behavioral(tree.root_node(), &source, file),
272 SecurityAnalysis::Mutability => analyze_mutability(tree.root_node(), &source, file),
273 };
274
275 findings.extend(file_findings);
276 }
277
278 let raw_result = serde_json::to_value(&findings).unwrap_or(Value::Array(vec![]));
280
281 Ok((findings, raw_result))
282}
283
284fn update_summary(
286 summary: &mut SecureSummary,
287 analysis: SecurityAnalysis,
288 findings: &[SecureFinding],
289) {
290 match analysis {
291 SecurityAnalysis::Taint => {
292 summary.taint_count = findings.len() as u32;
293 summary.taint_critical =
294 findings.iter().filter(|f| f.severity == "critical").count() as u32;
295 summary.unsafe_blocks = findings
296 .iter()
297 .filter(|f| f.category == "unsafe_block")
298 .count() as u32;
299 }
300 SecurityAnalysis::Resources => {
301 summary.leak_count = findings
302 .iter()
303 .filter(|f| f.category == "resource_leak")
304 .count() as u32;
305 summary.raw_pointer_ops = findings
306 .iter()
307 .filter(|f| f.category == "raw_pointer")
308 .count() as u32;
309 }
310 SecurityAnalysis::Bounds => {
311 summary.bounds_warnings =
312 findings.iter().filter(|f| f.category == "bounds").count() as u32;
313 summary.unwrap_calls =
314 findings.iter().filter(|f| f.category == "unwrap").count() as u32;
315 summary.todo_markers = findings
316 .iter()
317 .filter(|f| f.category == "todo_marker")
318 .count() as u32;
319 }
320 SecurityAnalysis::Contracts => {
321 summary.missing_contracts = findings.len() as u32;
322 }
323 SecurityAnalysis::Behavioral => {
324 }
326 SecurityAnalysis::Mutability => {
327 summary.mutable_params = findings.len() as u32;
328 }
329 }
330}
331
332fn severity_order(severity: &str) -> u8 {
334 match severity {
335 "critical" => 0,
336 "high" => 1,
337 "medium" => 2,
338 "low" => 3,
339 "info" => 4,
340 _ => 5,
341 }
342}
343
344const TAINT_SINKS: &[(&str, &str, &str)] = &[
350 ("cursor.execute", "SQL Injection", "critical"),
352 ("execute", "SQL Injection", "critical"),
353 ("os.system", "Command Injection", "critical"),
354 ("subprocess.call", "Command Injection", "critical"),
355 ("subprocess.run", "Command Injection", "high"),
356 ("subprocess.Popen", "Command Injection", "high"),
357 ("eval", "Code Injection", "critical"),
358 ("exec", "Code Injection", "critical"),
359 ("pickle.loads", "Insecure Deserialization", "critical"),
360 ("yaml.load", "Insecure Deserialization", "high"),
361 ("open", "Path Traversal", "high"),
362 ("render_template_string", "Template Injection", "high"),
363];
364
365fn analyze_taint(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
367 if is_rust_file(file) {
368 return analyze_rust_unsafe_blocks(source, file);
369 }
370
371 let mut findings = Vec::new();
372 let source_bytes = source.as_bytes();
373
374 analyze_fstring_injection(root, source_bytes, file, &mut findings);
379
380 analyze_string_concat_in_sinks(root, source_bytes, file, &mut findings);
382
383 findings
384}
385
386fn analyze_fstring_injection(
387 root: Node,
388 source: &[u8],
389 file: &Path,
390 findings: &mut Vec<SecureFinding>,
391) {
392 traverse_for_fstrings(root, source, file, findings);
393}
394
395fn traverse_for_fstrings(node: Node, source: &[u8], file: &Path, findings: &mut Vec<SecureFinding>) {
396 if node.kind() == "call" {
398 if let Some(func) = node.child_by_field_name("function") {
399 let func_text = node_text(func, source);
400
401 for (pattern, vuln_type, severity) in TAINT_SINKS {
403 if func_text.contains(pattern)
404 || func_text.ends_with(pattern.split('.').next_back().unwrap_or(pattern))
405 {
406 if let Some(args) = node.child_by_field_name("arguments") {
408 let args_text = node_text(args, source);
409 if args_text.contains("f\"")
410 || args_text.contains("f'")
411 || args_text.contains(".format(")
412 || args_text.contains(" + ")
413 {
414 findings.push(SecureFinding::new(
415 "taint",
416 *severity,
417 format!("{}: Potential {} - user input may flow to dangerous function",
418 vuln_type, vuln_type.to_lowercase()),
419 ).with_location(file.display().to_string(), node.start_position().row as u32 + 1));
420 }
421 }
422 }
423 }
424 }
425 }
426
427 for i in 0..node.child_count() {
429 if let Some(child) = node.child(i) {
430 traverse_for_fstrings(child, source, file, findings);
431 }
432 }
433}
434
435fn analyze_string_concat_in_sinks(
436 _root: Node,
437 _source: &[u8],
438 _file: &Path,
439 _findings: &mut Vec<SecureFinding>,
440) {
441 }
444
445const RESOURCE_CREATORS: &[&str] = &["open", "socket", "connect", "cursor", "urlopen"];
451
452fn analyze_resources(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
454 if is_rust_file(file) {
455 return analyze_rust_raw_pointers(source, file);
456 }
457
458 let mut findings = Vec::new();
459 let source_bytes = source.as_bytes();
460
461 find_leaked_resources(root, source_bytes, file, &mut findings);
463
464 findings
465}
466
467fn find_leaked_resources(
468 node: Node,
469 source: &[u8],
470 file: &Path,
471 findings: &mut Vec<SecureFinding>,
472) {
473 if node.kind() == "assignment" {
475 if let Some(right) = node.child_by_field_name("right") {
476 if right.kind() == "call" {
477 if let Some(func) = right.child_by_field_name("function") {
478 let func_text = node_text(func, source);
479 let func_name = func_text.split('.').next_back().unwrap_or(func_text);
480
481 if RESOURCE_CREATORS.contains(&func_name) {
482 if !is_inside_with(node) {
484 findings.push(
485 SecureFinding::new(
486 "resource_leak",
487 "high",
488 format!(
489 "Resource '{}' opened without context manager - may leak",
490 func_name
491 ),
492 )
493 .with_location(
494 file.display().to_string(),
495 node.start_position().row as u32 + 1,
496 ),
497 );
498 }
499 }
500 }
501 }
502 }
503 }
504
505 for i in 0..node.child_count() {
507 if let Some(child) = node.child(i) {
508 find_leaked_resources(child, source, file, findings);
509 }
510 }
511}
512
513fn is_inside_with(node: Node) -> bool {
514 let mut current = node.parent();
515 while let Some(parent) = current {
516 if parent.kind() == "with_statement" {
517 return true;
518 }
519 current = parent.parent();
520 }
521 false
522}
523
524fn analyze_bounds(_root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
530 if is_rust_file(file) {
531 return analyze_rust_bounds(source, file);
532 }
533
534 Vec::new()
536}
537
538fn analyze_contracts(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
544 Vec::new()
546}
547
548fn analyze_behavioral(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
554 let mut findings = Vec::new();
555 let source_bytes = source.as_bytes();
556
557 find_bare_except(root, source_bytes, file, &mut findings);
559
560 findings
561}
562
563fn find_bare_except(node: Node, source: &[u8], file: &Path, findings: &mut Vec<SecureFinding>) {
564 if node.kind() == "except_clause" {
566 let has_type = node.children(&mut node.walk()).any(|c| {
567 c.kind() == "as_pattern"
568 || (c.kind() == "identifier" && node_text(c, source) != "Exception")
569 });
570
571 if !has_type {
572 let text = node_text(node, source);
573 if text.starts_with("except:") || text.starts_with("except :") {
574 findings.push(
575 SecureFinding::new(
576 "behavioral",
577 "medium",
578 "Bare except clause catches all exceptions including KeyboardInterrupt",
579 )
580 .with_location(
581 file.display().to_string(),
582 node.start_position().row as u32 + 1,
583 ),
584 );
585 }
586 }
587 }
588
589 for i in 0..node.child_count() {
591 if let Some(child) = node.child(i) {
592 find_bare_except(child, source, file, findings);
593 }
594 }
595}
596
597fn analyze_mutability(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
603 Vec::new()
605}
606
607fn node_text<'a>(node: Node, source: &'a [u8]) -> &'a str {
612 std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
613}
614
615fn analyze_rust_unsafe_blocks(source: &str, file: &Path) -> Vec<SecureFinding> {
616 let mut findings = Vec::new();
617 for (idx, line) in source.lines().enumerate() {
618 let trimmed = line.trim();
619 if trimmed.starts_with("//") {
620 continue;
621 }
622 if trimmed.contains("unsafe {") || trimmed.starts_with("unsafe{") {
623 findings.push(
624 SecureFinding::new(
625 "unsafe_block",
626 "high",
627 "unsafe block detected; verify invariants and safety rationale",
628 )
629 .with_location(file.display().to_string(), (idx + 1) as u32),
630 );
631 }
632 }
633 findings
634}
635
636fn analyze_rust_raw_pointers(source: &str, file: &Path) -> Vec<SecureFinding> {
637 let mut findings = Vec::new();
638 for (idx, line) in source.lines().enumerate() {
639 let trimmed = line.trim();
640 if trimmed.starts_with("//") {
641 continue;
642 }
643 if trimmed.contains("std::ptr::")
644 || trimmed.contains("core::ptr::")
645 || trimmed.contains("ptr::read(")
646 || trimmed.contains("ptr::write(")
647 {
648 findings.push(
649 SecureFinding::new(
650 "raw_pointer",
651 "high",
652 "raw pointer operation detected; audit aliasing, lifetime, and bounds assumptions",
653 )
654 .with_location(file.display().to_string(), (idx + 1) as u32),
655 );
656 }
657 }
658 findings
659}
660
661fn analyze_rust_bounds(source: &str, file: &Path) -> Vec<SecureFinding> {
662 let mut findings = Vec::new();
663 let skip_test_only = is_rust_test_file(file);
664
665 for (idx, line) in source.lines().enumerate() {
666 let trimmed = line.trim();
667 if trimmed.starts_with("//") {
668 continue;
669 }
670
671 if !skip_test_only && trimmed.contains(".unwrap()") {
672 findings.push(
673 SecureFinding::new(
674 "unwrap",
675 "medium",
676 "unwrap() call in non-test code may panic at runtime",
677 )
678 .with_location(file.display().to_string(), (idx + 1) as u32),
679 );
680 }
681
682 if !skip_test_only && (trimmed.contains("todo!(") || trimmed.contains("unimplemented!(")) {
683 findings.push(
684 SecureFinding::new(
685 "todo_marker",
686 "low",
687 "todo!/unimplemented! marker found in non-test Rust code",
688 )
689 .with_location(file.display().to_string(), (idx + 1) as u32),
690 );
691 }
692 }
693
694 findings
695}
696
697fn format_text_report(report: &SecureReport) -> String {
702 let mut output = String::new();
703
704 output.push_str(&"=".repeat(60));
705 output.push('\n');
706 output.push_str(&format!(
707 "{}\n",
708 "SECURE - Security Analysis Dashboard".bold()
709 ));
710 output.push_str(&"=".repeat(60));
711 output.push_str("\n\n");
712 output.push_str(&format!("Path: {}\n\n", report.path));
713
714 if report.findings.is_empty() {
715 output.push_str(&format!("{}\n", "No security issues found.".green()));
716 } else {
717 output.push_str(&format!(
718 "{}\n",
719 "Severity | Category | Description".bold()
720 ));
721 output.push_str(&format!("{}\n", "-".repeat(60)));
722
723 for finding in &report.findings {
724 let severity_colored = match finding.severity.as_str() {
725 "critical" => finding.severity.red().bold().to_string(),
726 "high" => finding.severity.red().to_string(),
727 "medium" => finding.severity.yellow().to_string(),
728 "low" => finding.severity.blue().to_string(),
729 _ => finding.severity.clone(),
730 };
731 output.push_str(&format!(
732 "{:>8} | {:<14} | {}\n",
733 severity_colored, finding.category, finding.description
734 ));
735 if !finding.file.is_empty() {
736 output.push_str(&format!(
737 " | | {}:{}\n",
738 finding.file, finding.line
739 ));
740 }
741 }
742 }
743
744 output.push('\n');
745 output.push_str(&format!("{}\n", "Summary:".bold()));
746 output.push_str(&format!(
747 " Taint issues: {} ({} critical)\n",
748 report.summary.taint_count, report.summary.taint_critical
749 ));
750 output.push_str(&format!(
751 " Resource leaks: {}\n",
752 report.summary.leak_count
753 ));
754 output.push_str(&format!(
755 " Bounds warnings: {}\n",
756 report.summary.bounds_warnings
757 ));
758 output.push_str(&format!(
759 " Missing contracts: {}\n",
760 report.summary.missing_contracts
761 ));
762 output.push_str(&format!(
763 " Mutable params: {}\n",
764 report.summary.mutable_params
765 ));
766 output.push_str(&format!(
767 " Unsafe blocks: {}\n",
768 report.summary.unsafe_blocks
769 ));
770 output.push_str(&format!(
771 " Raw pointer ops: {}\n",
772 report.summary.raw_pointer_ops
773 ));
774 output.push_str(&format!(
775 " Unwrap calls: {}\n",
776 report.summary.unwrap_calls
777 ));
778 output.push_str(&format!(
779 " Todo markers: {}\n",
780 report.summary.todo_markers
781 ));
782 output.push('\n');
783 output.push_str(&format!("Elapsed: {:.2}ms\n", report.total_elapsed_ms));
784
785 output
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791 use tree_sitter::Parser;
792 use tempfile::TempDir;
793
794 fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
795 let path = dir.path().join(name);
796 fs::write(&path, content).unwrap();
797 path
798 }
799
800 #[test]
801 fn test_secure_args_default() {
802 let args = SecureArgs {
804 path: PathBuf::from("test.py"),
805 detail: None,
806 quick: false,
807 output: None,
808 };
809 assert!(!args.quick);
810 }
811
812 #[test]
813 fn test_severity_order() {
814 assert!(severity_order("critical") < severity_order("high"));
815 assert!(severity_order("high") < severity_order("medium"));
816 assert!(severity_order("medium") < severity_order("low"));
817 assert!(severity_order("low") < severity_order("info"));
818 }
819
820 #[test]
821 fn test_taint_analysis_finds_sql_injection() {
822 let source = r#"
823def query(user_input):
824 cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
825"#;
826
827 let mut parser = Parser::new();
828 parser
829 .set_language(&tree_sitter_python::LANGUAGE.into())
830 .unwrap();
831 let tree = parser.parse(source, None).unwrap();
832
833 let findings = analyze_taint(tree.root_node(), source, &PathBuf::from("test.py"));
834 assert!(!findings.is_empty(), "Should detect SQL injection");
835 assert!(findings.iter().any(|f| f.severity == "critical"));
836 }
837
838 #[test]
839 fn test_resource_analysis_finds_leak() {
840 let source = r#"
841def read_file():
842 f = open("test.txt")
843 data = f.read()
844 return data
845"#;
846
847 let mut parser = Parser::new();
848 parser
849 .set_language(&tree_sitter_python::LANGUAGE.into())
850 .unwrap();
851 let tree = parser.parse(source, None).unwrap();
852
853 let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
854 assert!(!findings.is_empty(), "Should detect resource leak");
855 }
856
857 #[test]
858 fn test_resource_analysis_no_leak_with_context() {
859 let source = r#"
860def read_file():
861 with open("test.txt") as f:
862 data = f.read()
863 return data
864"#;
865
866 let mut parser = Parser::new();
867 parser
868 .set_language(&tree_sitter_python::LANGUAGE.into())
869 .unwrap();
870 let tree = parser.parse(source, None).unwrap();
871
872 let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
873 assert!(
874 findings.is_empty(),
875 "Should not detect leak with context manager"
876 );
877 }
878
879 #[test]
880 fn test_collect_files_includes_rust() {
881 let temp = TempDir::new().unwrap();
882 create_test_file(&temp, "sample.py", "print('ok')");
883 create_test_file(&temp, "lib.rs", "fn main() {}");
884 create_test_file(&temp, "notes.txt", "ignore");
885
886 let files = collect_files(temp.path()).unwrap();
887 assert!(files.iter().any(|f| f.ends_with("sample.py")));
888 assert!(files.iter().any(|f| f.ends_with("lib.rs")));
889 assert!(!files.iter().any(|f| f.ends_with("notes.txt")));
890 }
891
892 #[test]
893 fn test_rust_secure_metrics_detected() {
894 let source = r#"
895use std::ptr;
896
897fn risky(user: &str) {
898 unsafe { ptr::write(user.as_ptr() as *mut u8, b'x'); }
899 let _v = Some(user).unwrap();
900 todo!("finish hardening");
901}
902"#;
903 let mut parser = Parser::new();
904 parser
905 .set_language(&tree_sitter_rust::LANGUAGE.into())
906 .unwrap();
907 let tree = parser.parse(source, None).unwrap();
908 let file = PathBuf::from("src/lib.rs");
909
910 let taint_findings = analyze_taint(tree.root_node(), source, &file);
911 let resource_findings = analyze_resources(tree.root_node(), source, &file);
912 let bounds_findings = analyze_bounds(tree.root_node(), source, &file);
913
914 assert!(!taint_findings.is_empty(), "Should count unsafe blocks");
915 assert!(
916 !resource_findings.is_empty(),
917 "Should count raw pointer ops"
918 );
919 assert!(
920 bounds_findings.iter().any(|f| f.category == "unwrap"),
921 "Should count unwrap calls"
922 );
923 assert!(
924 bounds_findings.iter().any(|f| f.category == "todo_marker"),
925 "Should count todo markers"
926 );
927 }
928}