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 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// =============================================================================
56// Security Analysis Types
57// =============================================================================
58
59/// Security sub-analysis types
60#[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    /// Get the analysis name
72    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
84/// Quick mode analyses (fast)
85pub const QUICK_ANALYSES: &[SecurityAnalysis] = &[
86    SecurityAnalysis::Taint,
87    SecurityAnalysis::Resources,
88    SecurityAnalysis::Bounds,
89];
90
91/// Full mode analyses (all)
92pub 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// =============================================================================
102// CLI Arguments
103// =============================================================================
104
105/// Security analysis dashboard aggregating multiple security checks
106#[derive(Debug, Args, Clone)]
107pub struct SecureArgs {
108    /// File path or directory to analyze
109    pub path: PathBuf,
110
111    /// Show details for specific sub-analysis
112    #[arg(long)]
113    pub detail: Option<String>,
114
115    /// Run quick mode (taint, resources, bounds only)
116    #[arg(long)]
117    pub quick: bool,
118
119    /// Write output to file instead of stdout
120    #[arg(long, short = 'o')]
121    pub output: Option<PathBuf>,
122}
123
124impl SecureArgs {
125    /// Run the secure command with CLI-provided format
126    pub fn run(&self, format: OutputFormat) -> anyhow::Result<()> {
127        run(self.clone(), format)
128    }
129}
130
131// =============================================================================
132// Implementation
133// =============================================================================
134
135/// Run the secure analysis
136pub fn run(args: SecureArgs, format: OutputFormat) -> anyhow::Result<()> {
137    let start = Instant::now();
138
139    // Validate path exists
140    if !args.path.exists() {
141        return Err(RemainingError::file_not_found(&args.path).into());
142    }
143
144    // Create report
145    let mut report = SecureReport::new(args.path.display().to_string());
146
147    // Initialize AST cache for shared parsing
148    let mut cache = AstCache::default();
149
150    // Determine which analyses to run
151    let analyses = if args.quick {
152        QUICK_ANALYSES
153    } else {
154        FULL_ANALYSES
155    };
156
157    // Collect files to analyze (auto-detect Python files)
158    let files = collect_files(&args.path)?;
159
160    // Run sub-analyses and collect findings
161    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
168        update_summary(&mut report.summary, *analysis, &findings);
169
170        // Collect findings
171        all_findings.extend(findings);
172
173        // Store raw result if requested
174        if args.detail.as_deref() == Some(analysis.name()) {
175            sub_results.insert(analysis.name().to_string(), raw_result);
176        }
177    }
178
179    // Sort findings by severity (critical first)
180    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    // Output
187    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            // SARIF/DOT not fully supported for secure, fall back to JSON
193            serde_json::to_string_pretty(&report)?
194        }
195    };
196
197    // Write output
198    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
207/// Collect supported files to analyze.
208fn 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        // Walk directory and collect Python/Rust files.
217        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    // Return empty vec if no files found (like vuln.rs does)
230    // The report will show 0 files scanned with no findings
231
232    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
251/// Run a specific security analysis on files
252fn 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        // Get or parse the AST
263        let tree = cache.get_or_parse(file, &source)?;
264
265        // Run analysis
266        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    // Create raw result
279    let raw_result = serde_json::to_value(&findings).unwrap_or(Value::Array(vec![]));
280
281    Ok((findings, raw_result))
282}
283
284/// Update summary based on findings
285fn 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            // Not tracked in summary
325        }
326        SecurityAnalysis::Mutability => {
327            summary.mutable_params = findings.len() as u32;
328        }
329    }
330}
331
332/// Get severity order (lower = more severe)
333fn 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
344// =============================================================================
345// Taint Analysis
346// =============================================================================
347
348/// Known taint sinks in Python
349const TAINT_SINKS: &[(&str, &str, &str)] = &[
350    // (pattern, vuln_type, severity)
351    ("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
365/// Analyze taint flows in a file
366fn 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    // Simple pattern matching for dangerous patterns
375    // In a full implementation, this would do data flow analysis
376
377    // Find f-strings and format strings used in dangerous contexts
378    analyze_fstring_injection(root, source_bytes, file, &mut findings);
379
380    // Find direct concatenation in dangerous sinks
381    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    // Check if this is a call to a dangerous function with an f-string
397    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            // Check if it's a dangerous sink
402            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                    // Check if arguments contain f-strings or format
407                    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    // Recurse
428    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    // Placeholder for string concatenation analysis
442    // In a full implementation, this would track string operations
443}
444
445// =============================================================================
446// Resource Analysis
447// =============================================================================
448
449/// Known resource creators
450const RESOURCE_CREATORS: &[&str] = &["open", "socket", "connect", "cursor", "urlopen"];
451
452/// Analyze resource leaks in a file
453fn 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 resource assignments outside of `with` statements
462    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    // Check if this is an assignment with a resource creator
474    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                        // Check if this is inside a with statement
483                        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    // Recurse
506    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
524// =============================================================================
525// Bounds Analysis
526// =============================================================================
527
528/// Analyze bounds/overflow issues in a file
529fn 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    // Placeholder for Python bounds analysis.
535    Vec::new()
536}
537
538// =============================================================================
539// Contracts Analysis
540// =============================================================================
541
542/// Analyze missing contracts in a file
543fn analyze_contracts(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
544    // Placeholder - would check for functions without type hints, docstrings, or assertions
545    Vec::new()
546}
547
548// =============================================================================
549// Behavioral Analysis
550// =============================================================================
551
552/// Analyze behavioral issues (exception handling, state) in a file
553fn 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 clauses
558    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    // Check for except clauses without exception type
565    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    // Recurse
590    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
597// =============================================================================
598// Mutability Analysis
599// =============================================================================
600
601/// Analyze mutability issues in a file
602fn analyze_mutability(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
603    // Placeholder - would check for mutable default arguments, etc.
604    Vec::new()
605}
606
607// =============================================================================
608// Utilities
609// =============================================================================
610
611fn 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
697// =============================================================================
698// Text Output
699// =============================================================================
700
701fn 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        // Test that default values are set correctly
803        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}