Skip to main content

tldr_cli/commands/remaining/
secure.rs

1//! Secure Command - Security Analysis Dashboard
2//!
3//! Aggregates security sub-analyses (taint, resources, bounds, contracts,
4//! behavioral, mutability) into a severity-sorted security report.
5//!
6//! # Sub-analyses
7//!
8//! - `taint`: Detect data flow from untrusted sources to sensitive sinks
9//! - `resources`: Detect resource leaks (files, connections)
10//! - `bounds`: Detect potential buffer overflows and bounds issues
11//! - `contracts`: Analyze pre/postconditions (full mode only)
12//! - `behavioral`: Analyze exception handling and state transitions (full mode only)
13//! - `mutability`: Detect mutable parameter issues (full mode only)
14//!
15//! # Quick Mode
16//!
17//! Quick mode (`--quick`) runs only the fast analyses:
18//! - taint, resources, bounds
19//!
20//! Full mode adds:
21//! - contracts, behavioral, mutability
22//!
23//! # Example
24//!
25//! ```bash
26//! # Analyze a file
27//! tldr secure src/app.py
28//!
29//! # Quick mode (faster)
30//! tldr secure src/app.py --quick
31//!
32//! # Show detail for sub-analysis
33//! tldr secure src/app.py --detail taint
34//!
35//! # Text output
36//! tldr secure src/app.py -f text
37//! ```
38
39use 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 tldr_core::walker::ProjectWalker;
48use tldr_core::Language;
49use tree_sitter::Node;
50
51use crate::output::OutputFormat;
52
53use super::ast_cache::AstCache;
54use super::error::{RemainingError, RemainingResult};
55use super::types::{SecureFinding, SecureReport, SecureSummary};
56
57// =============================================================================
58// Security Analysis Types
59// =============================================================================
60
61/// Security sub-analysis types
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum SecurityAnalysis {
64    Taint,
65    Resources,
66    Bounds,
67    Contracts,
68    Behavioral,
69    Mutability,
70}
71
72impl SecurityAnalysis {
73    /// Get the analysis name
74    pub fn name(&self) -> &'static str {
75        match self {
76            Self::Taint => "taint",
77            Self::Resources => "resources",
78            Self::Bounds => "bounds",
79            Self::Contracts => "contracts",
80            Self::Behavioral => "behavioral",
81            Self::Mutability => "mutability",
82        }
83    }
84}
85
86/// Quick mode analyses (fast)
87pub const QUICK_ANALYSES: &[SecurityAnalysis] = &[
88    SecurityAnalysis::Taint,
89    SecurityAnalysis::Resources,
90    SecurityAnalysis::Bounds,
91];
92
93/// Full mode analyses (all)
94pub const FULL_ANALYSES: &[SecurityAnalysis] = &[
95    SecurityAnalysis::Taint,
96    SecurityAnalysis::Resources,
97    SecurityAnalysis::Bounds,
98    SecurityAnalysis::Contracts,
99    SecurityAnalysis::Behavioral,
100    SecurityAnalysis::Mutability,
101];
102
103// =============================================================================
104// CLI Arguments
105// =============================================================================
106
107/// Security analysis dashboard aggregating multiple security checks
108#[derive(Debug, Args, Clone)]
109pub struct SecureArgs {
110    /// File path or directory to analyze
111    pub path: PathBuf,
112
113    /// Programming language to filter by (auto-detected if omitted)
114    #[arg(long, short = 'l')]
115    pub lang: Option<Language>,
116
117    /// Show details for specific sub-analysis
118    #[arg(long)]
119    pub detail: Option<String>,
120
121    /// Run quick mode (taint, resources, bounds only)
122    #[arg(long)]
123    pub quick: bool,
124
125    /// Write output to file instead of stdout
126    #[arg(long, short = 'o')]
127    pub output: Option<PathBuf>,
128
129    /// Walk vendored/build dirs (node_modules, target, dist, etc.) that would normally be skipped.
130    #[arg(long)]
131    pub no_default_ignore: bool,
132}
133
134impl SecureArgs {
135    /// Run the secure command with CLI-provided format
136    pub fn run(&self, format: OutputFormat) -> anyhow::Result<()> {
137        run(self.clone(), format)
138    }
139}
140
141// =============================================================================
142// Implementation
143// =============================================================================
144
145/// Run the secure analysis
146pub fn run(args: SecureArgs, format: OutputFormat) -> anyhow::Result<()> {
147    let start = Instant::now();
148
149    // Validate path exists
150    if !args.path.exists() {
151        return Err(RemainingError::file_not_found(&args.path).into());
152    }
153
154    // Create report
155    let mut report = SecureReport::new(args.path.display().to_string());
156
157    // Initialize AST cache for shared parsing
158    let mut cache = AstCache::default();
159
160    // Determine which analyses to run
161    let analyses = if args.quick {
162        QUICK_ANALYSES
163    } else {
164        FULL_ANALYSES
165    };
166
167    // Collect files to analyze (auto-detect Python files)
168    let files = collect_files(&args.path, args.lang, args.no_default_ignore)?;
169
170    // Run sub-analyses and collect findings
171    let mut all_findings = Vec::new();
172    let mut sub_results: HashMap<String, Value> = HashMap::new();
173
174    for analysis in analyses {
175        let (findings, raw_result) = run_security_analysis(*analysis, &files, &mut cache)?;
176
177        // Update summary
178        update_summary(&mut report.summary, *analysis, &findings);
179
180        // Collect findings
181        all_findings.extend(findings);
182
183        // Store raw result if requested
184        if args.detail.as_deref() == Some(analysis.name()) {
185            sub_results.insert(analysis.name().to_string(), raw_result);
186        }
187    }
188
189    // Sort findings by severity (critical first)
190    all_findings.sort_by(|a, b| severity_order(&a.severity).cmp(&severity_order(&b.severity)));
191
192    report.findings = all_findings;
193    report.sub_results = sub_results;
194    report.total_elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
195
196    // Output
197    let output_str = match format {
198        OutputFormat::Json => serde_json::to_string_pretty(&report)?,
199        OutputFormat::Compact => serde_json::to_string(&report)?,
200        OutputFormat::Text => format_text_report(&report),
201        OutputFormat::Sarif | OutputFormat::Dot => {
202            // SARIF/DOT not fully supported for secure, fall back to JSON
203            serde_json::to_string_pretty(&report)?
204        }
205    };
206
207    // Write output
208    if let Some(output_path) = &args.output {
209        fs::write(output_path, &output_str)?;
210    } else {
211        println!("{}", output_str);
212    }
213
214    Ok(())
215}
216
217/// Collect supported files to analyze.
218fn collect_files(
219    path: &Path,
220    lang: Option<Language>,
221    no_default_ignore: bool,
222) -> RemainingResult<Vec<PathBuf>> {
223    let mut files = Vec::new();
224
225    if path.is_file() {
226        if is_supported_secure_file(path, lang) {
227            files.push(path.to_path_buf());
228        }
229    } else if path.is_dir() {
230        // Walk directory and collect supported source files.
231        let mut walker = ProjectWalker::new(path).max_depth(10);
232        if no_default_ignore {
233            walker = walker.no_default_ignore();
234        }
235        for entry in walker.iter() {
236            let p = entry.path();
237            if p.is_file() && is_supported_secure_file(p, lang) {
238                files.push(p.to_path_buf());
239            }
240        }
241    }
242
243    // Return empty vec if no files found (like vuln.rs does)
244    // The report will show 0 files scanned with no findings
245
246    Ok(files)
247}
248
249/// Check whether `path` is a source file the secure analyzer should scan.
250///
251/// With `lang = Some(L)`, only matches that language's extensions. With
252/// `lang = None`, preserves the historical behavior of `py | rs` (the
253/// languages the sub-analyzers natively support).
254fn is_supported_secure_file(path: &std::path::Path, lang: Option<Language>) -> bool {
255    let ext = match path.extension().and_then(|e| e.to_str()) {
256        Some(e) => e,
257        None => return false,
258    };
259    match lang {
260        Some(Language::TypeScript) => matches!(ext, "ts" | "tsx"),
261        Some(Language::JavaScript) => matches!(ext, "js" | "mjs" | "cjs" | "jsx"),
262        Some(Language::Python) => ext == "py",
263        Some(Language::Rust) => ext == "rs",
264        Some(Language::Go) => ext == "go",
265        Some(Language::Java) => ext == "java",
266        Some(Language::C) => matches!(ext, "c" | "h"),
267        Some(Language::Cpp) => matches!(ext, "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx"),
268        Some(Language::CSharp) => ext == "cs",
269        Some(Language::Ruby) => ext == "rb",
270        Some(Language::Php) => ext == "php",
271        Some(Language::Kotlin) => matches!(ext, "kt" | "kts"),
272        Some(Language::Swift) => ext == "swift",
273        Some(Language::Scala) => ext == "scala",
274        Some(Language::Elixir) => matches!(ext, "ex" | "exs"),
275        Some(Language::Lua) => ext == "lua",
276        Some(Language::Luau) => ext == "luau",
277        Some(Language::Ocaml) => matches!(ext, "ml" | "mli"),
278        None => matches!(ext, "py" | "rs"),
279    }
280}
281
282fn is_rust_file(path: &std::path::Path) -> bool {
283    matches!(path.extension().and_then(|e| e.to_str()), Some("rs"))
284}
285
286fn is_rust_test_file(path: &std::path::Path) -> bool {
287    let p = path.to_string_lossy();
288    p.contains("/tests/")
289        || p.contains("\\tests\\")
290        || p.ends_with("_test.rs")
291        || p.ends_with("tests.rs")
292}
293
294/// Run a specific security analysis on files
295fn run_security_analysis(
296    analysis: SecurityAnalysis,
297    files: &[PathBuf],
298    cache: &mut AstCache,
299) -> RemainingResult<(Vec<SecureFinding>, Value)> {
300    let mut findings = Vec::new();
301
302    for file in files {
303        let source = fs::read_to_string(file)?;
304
305        // Get or parse the AST
306        let tree = cache.get_or_parse(file, &source)?;
307
308        // Run analysis
309        let file_findings = match analysis {
310            SecurityAnalysis::Taint => analyze_taint(tree.root_node(), &source, file),
311            SecurityAnalysis::Resources => analyze_resources(tree.root_node(), &source, file),
312            SecurityAnalysis::Bounds => analyze_bounds(tree.root_node(), &source, file),
313            SecurityAnalysis::Contracts => analyze_contracts(tree.root_node(), &source, file),
314            SecurityAnalysis::Behavioral => analyze_behavioral(tree.root_node(), &source, file),
315            SecurityAnalysis::Mutability => analyze_mutability(tree.root_node(), &source, file),
316        };
317
318        findings.extend(file_findings);
319    }
320
321    // Create raw result
322    let raw_result = serde_json::to_value(&findings).unwrap_or(Value::Array(vec![]));
323
324    Ok((findings, raw_result))
325}
326
327/// Update summary based on findings
328fn update_summary(
329    summary: &mut SecureSummary,
330    analysis: SecurityAnalysis,
331    findings: &[SecureFinding],
332) {
333    match analysis {
334        SecurityAnalysis::Taint => {
335            summary.taint_count = findings.len() as u32;
336            summary.taint_critical =
337                findings.iter().filter(|f| f.severity == "critical").count() as u32;
338            summary.unsafe_blocks = findings
339                .iter()
340                .filter(|f| f.category == "unsafe_block")
341                .count() as u32;
342        }
343        SecurityAnalysis::Resources => {
344            summary.leak_count = findings
345                .iter()
346                .filter(|f| f.category == "resource_leak")
347                .count() as u32;
348            summary.raw_pointer_ops = findings
349                .iter()
350                .filter(|f| f.category == "raw_pointer")
351                .count() as u32;
352        }
353        SecurityAnalysis::Bounds => {
354            summary.bounds_warnings =
355                findings.iter().filter(|f| f.category == "bounds").count() as u32;
356            summary.unwrap_calls =
357                findings.iter().filter(|f| f.category == "unwrap").count() as u32;
358            summary.todo_markers = findings
359                .iter()
360                .filter(|f| f.category == "todo_marker")
361                .count() as u32;
362        }
363        SecurityAnalysis::Contracts => {
364            summary.missing_contracts = findings.len() as u32;
365        }
366        SecurityAnalysis::Behavioral => {
367            // Not tracked in summary
368        }
369        SecurityAnalysis::Mutability => {
370            summary.mutable_params = findings.len() as u32;
371        }
372    }
373}
374
375/// Get severity order (lower = more severe)
376fn severity_order(severity: &str) -> u8 {
377    match severity {
378        "critical" => 0,
379        "high" => 1,
380        "medium" => 2,
381        "low" => 3,
382        "info" => 4,
383        _ => 5,
384    }
385}
386
387// =============================================================================
388// Taint Analysis
389// =============================================================================
390
391/// Known taint sinks in Python
392const TAINT_SINKS: &[(&str, &str, &str)] = &[
393    // (pattern, vuln_type, severity)
394    ("cursor.execute", "SQL Injection", "critical"),
395    ("execute", "SQL Injection", "critical"),
396    ("os.system", "Command Injection", "critical"),
397    ("subprocess.call", "Command Injection", "critical"),
398    ("subprocess.run", "Command Injection", "high"),
399    ("subprocess.Popen", "Command Injection", "high"),
400    ("eval", "Code Injection", "critical"),
401    ("exec", "Code Injection", "critical"),
402    ("pickle.loads", "Insecure Deserialization", "critical"),
403    ("yaml.load", "Insecure Deserialization", "high"),
404    ("open", "Path Traversal", "high"),
405    ("render_template_string", "Template Injection", "high"),
406];
407
408/// Analyze taint flows in a file
409fn analyze_taint(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
410    if is_rust_file(file) {
411        return analyze_rust_unsafe_blocks(source, file);
412    }
413
414    let mut findings = Vec::new();
415    let source_bytes = source.as_bytes();
416
417    // Simple pattern matching for dangerous patterns
418    // In a full implementation, this would do data flow analysis
419
420    // Find f-strings and format strings used in dangerous contexts
421    analyze_fstring_injection(root, source_bytes, file, &mut findings);
422
423    // Find direct concatenation in dangerous sinks
424    analyze_string_concat_in_sinks(root, source_bytes, file, &mut findings);
425
426    findings
427}
428
429fn analyze_fstring_injection(
430    root: Node,
431    source: &[u8],
432    file: &Path,
433    findings: &mut Vec<SecureFinding>,
434) {
435    traverse_for_fstrings(root, source, file, findings);
436}
437
438fn traverse_for_fstrings(
439    node: Node,
440    source: &[u8],
441    file: &Path,
442    findings: &mut Vec<SecureFinding>,
443) {
444    // Check if this is a call to a dangerous function with an f-string
445    if node.kind() == "call" {
446        if let Some(func) = node.child_by_field_name("function") {
447            let func_text = node_text(func, source);
448
449            // Check if it's a dangerous sink
450            for (pattern, vuln_type, severity) in TAINT_SINKS {
451                if func_text.contains(pattern)
452                    || func_text.ends_with(pattern.split('.').next_back().unwrap_or(pattern))
453                {
454                    // Check if arguments contain f-strings or format
455                    if let Some(args) = node.child_by_field_name("arguments") {
456                        let args_text = node_text(args, source);
457                        if args_text.contains("f\"")
458                            || args_text.contains("f'")
459                            || args_text.contains(".format(")
460                            || args_text.contains(" + ")
461                        {
462                            findings.push(SecureFinding::new(
463                                "taint",
464                                *severity,
465                                format!("{}: Potential {} - user input may flow to dangerous function", 
466                                    vuln_type, vuln_type.to_lowercase()),
467                            ).with_location(file.display().to_string(), node.start_position().row as u32 + 1));
468                        }
469                    }
470                }
471            }
472        }
473    }
474
475    // Recurse
476    for i in 0..node.child_count() {
477        if let Some(child) = node.child(i) {
478            traverse_for_fstrings(child, source, file, findings);
479        }
480    }
481}
482
483fn analyze_string_concat_in_sinks(
484    _root: Node,
485    _source: &[u8],
486    _file: &Path,
487    _findings: &mut Vec<SecureFinding>,
488) {
489    // Placeholder for string concatenation analysis
490    // In a full implementation, this would track string operations
491}
492
493// =============================================================================
494// Resource Analysis
495// =============================================================================
496
497/// Known resource creators
498const RESOURCE_CREATORS: &[&str] = &["open", "socket", "connect", "cursor", "urlopen"];
499
500/// Analyze resource leaks in a file
501fn analyze_resources(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
502    if is_rust_file(file) {
503        return analyze_rust_raw_pointers(source, file);
504    }
505
506    let mut findings = Vec::new();
507    let source_bytes = source.as_bytes();
508
509    // Find resource assignments outside of `with` statements
510    find_leaked_resources(root, source_bytes, file, &mut findings);
511
512    findings
513}
514
515fn find_leaked_resources(
516    node: Node,
517    source: &[u8],
518    file: &Path,
519    findings: &mut Vec<SecureFinding>,
520) {
521    // Check if this is an assignment with a resource creator
522    if node.kind() == "assignment" {
523        if let Some(right) = node.child_by_field_name("right") {
524            if right.kind() == "call" {
525                if let Some(func) = right.child_by_field_name("function") {
526                    let func_text = node_text(func, source);
527                    let func_name = func_text.split('.').next_back().unwrap_or(func_text);
528
529                    if RESOURCE_CREATORS.contains(&func_name) {
530                        // Check if this is inside a with statement
531                        if !is_inside_with(node) {
532                            findings.push(
533                                SecureFinding::new(
534                                    "resource_leak",
535                                    "high",
536                                    format!(
537                                        "Resource '{}' opened without context manager - may leak",
538                                        func_name
539                                    ),
540                                )
541                                .with_location(
542                                    file.display().to_string(),
543                                    node.start_position().row as u32 + 1,
544                                ),
545                            );
546                        }
547                    }
548                }
549            }
550        }
551    }
552
553    // Recurse
554    for i in 0..node.child_count() {
555        if let Some(child) = node.child(i) {
556            find_leaked_resources(child, source, file, findings);
557        }
558    }
559}
560
561fn is_inside_with(node: Node) -> bool {
562    let mut current = node.parent();
563    while let Some(parent) = current {
564        if parent.kind() == "with_statement" {
565            return true;
566        }
567        current = parent.parent();
568    }
569    false
570}
571
572// =============================================================================
573// Bounds Analysis
574// =============================================================================
575
576/// Analyze bounds/overflow issues in a file
577fn analyze_bounds(_root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
578    if is_rust_file(file) {
579        return analyze_rust_bounds(source, file);
580    }
581
582    // Placeholder for Python bounds analysis.
583    Vec::new()
584}
585
586// =============================================================================
587// Contracts Analysis
588// =============================================================================
589
590/// Analyze missing contracts in a file
591fn analyze_contracts(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
592    // Placeholder - would check for functions without type hints, docstrings, or assertions
593    Vec::new()
594}
595
596// =============================================================================
597// Behavioral Analysis
598// =============================================================================
599
600/// Analyze behavioral issues (exception handling, state) in a file
601fn analyze_behavioral(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
602    let mut findings = Vec::new();
603    let source_bytes = source.as_bytes();
604
605    // Find bare except clauses
606    find_bare_except(root, source_bytes, file, &mut findings);
607
608    findings
609}
610
611fn find_bare_except(node: Node, source: &[u8], file: &Path, findings: &mut Vec<SecureFinding>) {
612    // Check for except clauses without exception type
613    if node.kind() == "except_clause" {
614        let has_type = node.children(&mut node.walk()).any(|c| {
615            c.kind() == "as_pattern"
616                || (c.kind() == "identifier" && node_text(c, source) != "Exception")
617        });
618
619        if !has_type {
620            let text = node_text(node, source);
621            if text.starts_with("except:") || text.starts_with("except :") {
622                findings.push(
623                    SecureFinding::new(
624                        "behavioral",
625                        "medium",
626                        "Bare except clause catches all exceptions including KeyboardInterrupt",
627                    )
628                    .with_location(
629                        file.display().to_string(),
630                        node.start_position().row as u32 + 1,
631                    ),
632                );
633            }
634        }
635    }
636
637    // Recurse
638    for i in 0..node.child_count() {
639        if let Some(child) = node.child(i) {
640            find_bare_except(child, source, file, findings);
641        }
642    }
643}
644
645// =============================================================================
646// Mutability Analysis
647// =============================================================================
648
649/// Analyze mutability issues in a file
650fn analyze_mutability(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
651    // Placeholder - would check for mutable default arguments, etc.
652    Vec::new()
653}
654
655// =============================================================================
656// Utilities
657// =============================================================================
658
659fn node_text<'a>(node: Node, source: &'a [u8]) -> &'a str {
660    std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
661}
662
663fn analyze_rust_unsafe_blocks(source: &str, file: &Path) -> Vec<SecureFinding> {
664    let mut findings = Vec::new();
665    for (idx, line) in source.lines().enumerate() {
666        let trimmed = line.trim();
667        if trimmed.starts_with("//") {
668            continue;
669        }
670        if trimmed.contains("unsafe {") || trimmed.starts_with("unsafe{") {
671            findings.push(
672                SecureFinding::new(
673                    "unsafe_block",
674                    "high",
675                    "unsafe block detected; verify invariants and safety rationale",
676                )
677                .with_location(file.display().to_string(), (idx + 1) as u32),
678            );
679        }
680    }
681    findings
682}
683
684fn analyze_rust_raw_pointers(source: &str, file: &Path) -> Vec<SecureFinding> {
685    let mut findings = Vec::new();
686    for (idx, line) in source.lines().enumerate() {
687        let trimmed = line.trim();
688        if trimmed.starts_with("//") {
689            continue;
690        }
691        if trimmed.contains("std::ptr::")
692            || trimmed.contains("core::ptr::")
693            || trimmed.contains("ptr::read(")
694            || trimmed.contains("ptr::write(")
695        {
696            findings.push(
697                SecureFinding::new(
698                    "raw_pointer",
699                    "high",
700                    "raw pointer operation detected; audit aliasing, lifetime, and bounds assumptions",
701                )
702                .with_location(file.display().to_string(), (idx + 1) as u32),
703            );
704        }
705    }
706    findings
707}
708
709fn analyze_rust_bounds(source: &str, file: &Path) -> Vec<SecureFinding> {
710    let mut findings = Vec::new();
711    let skip_test_only = is_rust_test_file(file);
712
713    for (idx, line) in source.lines().enumerate() {
714        let trimmed = line.trim();
715        if trimmed.starts_with("//") {
716            continue;
717        }
718
719        if !skip_test_only && trimmed.contains(".unwrap()") {
720            findings.push(
721                SecureFinding::new(
722                    "unwrap",
723                    "medium",
724                    "unwrap() call in non-test code may panic at runtime",
725                )
726                .with_location(file.display().to_string(), (idx + 1) as u32),
727            );
728        }
729
730        if !skip_test_only && (trimmed.contains("todo!(") || trimmed.contains("unimplemented!(")) {
731            findings.push(
732                SecureFinding::new(
733                    "todo_marker",
734                    "low",
735                    "todo!/unimplemented! marker found in non-test Rust code",
736                )
737                .with_location(file.display().to_string(), (idx + 1) as u32),
738            );
739        }
740    }
741
742    findings
743}
744
745// =============================================================================
746// Text Output
747// =============================================================================
748
749fn format_text_report(report: &SecureReport) -> String {
750    let mut output = String::new();
751
752    output.push_str(&"=".repeat(60));
753    output.push('\n');
754    output.push_str(&format!(
755        "{}\n",
756        "SECURE - Security Analysis Dashboard".bold()
757    ));
758    output.push_str(&"=".repeat(60));
759    output.push_str("\n\n");
760    output.push_str(&format!("Path: {}\n\n", report.path));
761
762    if report.findings.is_empty() {
763        output.push_str(&format!("{}\n", "No security issues found.".green()));
764    } else {
765        output.push_str(&format!(
766            "{}\n",
767            "Severity | Category       | Description".bold()
768        ));
769        output.push_str(&format!("{}\n", "-".repeat(60)));
770
771        for finding in &report.findings {
772            let severity_colored = match finding.severity.as_str() {
773                "critical" => finding.severity.red().bold().to_string(),
774                "high" => finding.severity.red().to_string(),
775                "medium" => finding.severity.yellow().to_string(),
776                "low" => finding.severity.blue().to_string(),
777                _ => finding.severity.clone(),
778            };
779            output.push_str(&format!(
780                "{:>8} | {:<14} | {}\n",
781                severity_colored, finding.category, finding.description
782            ));
783            if !finding.file.is_empty() {
784                output.push_str(&format!(
785                    "         |                | {}:{}\n",
786                    finding.file, finding.line
787                ));
788            }
789        }
790    }
791
792    output.push('\n');
793    output.push_str(&format!("{}\n", "Summary:".bold()));
794    output.push_str(&format!(
795        "  Taint issues:      {} ({} critical)\n",
796        report.summary.taint_count, report.summary.taint_critical
797    ));
798    output.push_str(&format!(
799        "  Resource leaks:    {}\n",
800        report.summary.leak_count
801    ));
802    output.push_str(&format!(
803        "  Bounds warnings:   {}\n",
804        report.summary.bounds_warnings
805    ));
806    output.push_str(&format!(
807        "  Missing contracts: {}\n",
808        report.summary.missing_contracts
809    ));
810    output.push_str(&format!(
811        "  Mutable params:    {}\n",
812        report.summary.mutable_params
813    ));
814    output.push_str(&format!(
815        "  Unsafe blocks:     {}\n",
816        report.summary.unsafe_blocks
817    ));
818    output.push_str(&format!(
819        "  Raw pointer ops:   {}\n",
820        report.summary.raw_pointer_ops
821    ));
822    output.push_str(&format!(
823        "  Unwrap calls:      {}\n",
824        report.summary.unwrap_calls
825    ));
826    output.push_str(&format!(
827        "  Todo markers:      {}\n",
828        report.summary.todo_markers
829    ));
830    output.push('\n');
831    output.push_str(&format!("Elapsed: {:.2}ms\n", report.total_elapsed_ms));
832
833    output
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use tempfile::TempDir;
840    use tree_sitter::Parser;
841
842    fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
843        let path = dir.path().join(name);
844        fs::write(&path, content).unwrap();
845        path
846    }
847
848    #[test]
849    fn test_secure_args_default() {
850        // Test that default values are set correctly
851        let args = SecureArgs {
852            path: PathBuf::from("test.py"),
853            lang: None,
854            detail: None,
855            quick: false,
856            output: None,
857            no_default_ignore: false,
858        };
859        assert!(!args.quick);
860    }
861
862    #[test]
863    fn test_severity_order() {
864        assert!(severity_order("critical") < severity_order("high"));
865        assert!(severity_order("high") < severity_order("medium"));
866        assert!(severity_order("medium") < severity_order("low"));
867        assert!(severity_order("low") < severity_order("info"));
868    }
869
870    #[test]
871    fn test_taint_analysis_finds_sql_injection() {
872        let source = r#"
873def query(user_input):
874    cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")
875"#;
876
877        let mut parser = Parser::new();
878        parser
879            .set_language(&tree_sitter_python::LANGUAGE.into())
880            .unwrap();
881        let tree = parser.parse(source, None).unwrap();
882
883        let findings = analyze_taint(tree.root_node(), source, &PathBuf::from("test.py"));
884        assert!(!findings.is_empty(), "Should detect SQL injection");
885        assert!(findings.iter().any(|f| f.severity == "critical"));
886    }
887
888    #[test]
889    fn test_resource_analysis_finds_leak() {
890        let source = r#"
891def read_file():
892    f = open("test.txt")
893    data = f.read()
894    return data
895"#;
896
897        let mut parser = Parser::new();
898        parser
899            .set_language(&tree_sitter_python::LANGUAGE.into())
900            .unwrap();
901        let tree = parser.parse(source, None).unwrap();
902
903        let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
904        assert!(!findings.is_empty(), "Should detect resource leak");
905    }
906
907    #[test]
908    fn test_resource_analysis_no_leak_with_context() {
909        let source = r#"
910def read_file():
911    with open("test.txt") as f:
912        data = f.read()
913    return data
914"#;
915
916        let mut parser = Parser::new();
917        parser
918            .set_language(&tree_sitter_python::LANGUAGE.into())
919            .unwrap();
920        let tree = parser.parse(source, None).unwrap();
921
922        let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
923        assert!(
924            findings.is_empty(),
925            "Should not detect leak with context manager"
926        );
927    }
928
929    #[test]
930    fn test_collect_files_includes_rust() {
931        let temp = TempDir::new().unwrap();
932        create_test_file(&temp, "sample.py", "print('ok')");
933        create_test_file(&temp, "lib.rs", "fn main() {}");
934        create_test_file(&temp, "notes.txt", "ignore");
935
936        let files = collect_files(temp.path(), None, false).unwrap();
937        assert!(files.iter().any(|f| f.ends_with("sample.py")));
938        assert!(files.iter().any(|f| f.ends_with("lib.rs")));
939        assert!(!files.iter().any(|f| f.ends_with("notes.txt")));
940    }
941
942    #[test]
943    fn test_rust_secure_metrics_detected() {
944        let source = r#"
945use std::ptr;
946
947fn risky(user: &str) {
948    unsafe { ptr::write(user.as_ptr() as *mut u8, b'x'); }
949    let _v = Some(user).unwrap();
950    todo!("finish hardening");
951}
952"#;
953        let mut parser = Parser::new();
954        parser
955            .set_language(&tree_sitter_rust::LANGUAGE.into())
956            .unwrap();
957        let tree = parser.parse(source, None).unwrap();
958        let file = PathBuf::from("src/lib.rs");
959
960        let taint_findings = analyze_taint(tree.root_node(), source, &file);
961        let resource_findings = analyze_resources(tree.root_node(), source, &file);
962        let bounds_findings = analyze_bounds(tree.root_node(), source, &file);
963
964        assert!(!taint_findings.is_empty(), "Should count unsafe blocks");
965        assert!(
966            !resource_findings.is_empty(),
967            "Should count raw pointer ops"
968        );
969        assert!(
970            bounds_findings.iter().any(|f| f.category == "unwrap"),
971            "Should count unwrap calls"
972        );
973        assert!(
974            bounds_findings.iter().any(|f| f.category == "todo_marker"),
975            "Should count todo markers"
976        );
977    }
978}