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 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#[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 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
86pub const QUICK_ANALYSES: &[SecurityAnalysis] = &[
88 SecurityAnalysis::Taint,
89 SecurityAnalysis::Resources,
90 SecurityAnalysis::Bounds,
91];
92
93pub 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#[derive(Debug, Args, Clone)]
109pub struct SecureArgs {
110 pub path: PathBuf,
112
113 #[arg(long, short = 'l')]
115 pub lang: Option<Language>,
116
117 #[arg(long)]
119 pub detail: Option<String>,
120
121 #[arg(long)]
123 pub quick: bool,
124
125 #[arg(long, short = 'o')]
127 pub output: Option<PathBuf>,
128
129 #[arg(long)]
131 pub no_default_ignore: bool,
132}
133
134impl SecureArgs {
135 pub fn run(&self, format: OutputFormat) -> anyhow::Result<()> {
137 run(self.clone(), format)
138 }
139}
140
141pub fn run(args: SecureArgs, format: OutputFormat) -> anyhow::Result<()> {
147 let start = Instant::now();
148
149 if !args.path.exists() {
151 return Err(RemainingError::file_not_found(&args.path).into());
152 }
153
154 let mut report = SecureReport::new(args.path.display().to_string());
156
157 let mut cache = AstCache::default();
159
160 let analyses = if args.quick {
162 QUICK_ANALYSES
163 } else {
164 FULL_ANALYSES
165 };
166
167 let files = collect_files(&args.path, args.lang, args.no_default_ignore)?;
169
170 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(&mut report.summary, *analysis, &findings);
179
180 all_findings.extend(findings);
182
183 if args.detail.as_deref() == Some(analysis.name()) {
185 sub_results.insert(analysis.name().to_string(), raw_result);
186 }
187 }
188
189 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 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 serde_json::to_string_pretty(&report)?
204 }
205 };
206
207 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
217fn 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 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 Ok(files)
247}
248
249fn 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
294fn 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 let tree = cache.get_or_parse(file, &source)?;
307
308 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 let raw_result = serde_json::to_value(&findings).unwrap_or(Value::Array(vec![]));
323
324 Ok((findings, raw_result))
325}
326
327fn 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 }
369 SecurityAnalysis::Mutability => {
370 summary.mutable_params = findings.len() as u32;
371 }
372 }
373}
374
375fn 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
387const TAINT_SINKS: &[(&str, &str, &str)] = &[
393 ("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
408fn 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 analyze_fstring_injection(root, source_bytes, file, &mut findings);
422
423 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 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 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 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 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 }
492
493const RESOURCE_CREATORS: &[&str] = &["open", "socket", "connect", "cursor", "urlopen"];
499
500fn 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_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 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 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 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
572fn 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 Vec::new()
584}
585
586fn analyze_contracts(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
592 Vec::new()
594}
595
596fn 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(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 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 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
645fn analyze_mutability(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
651 Vec::new()
653}
654
655fn 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
745fn 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 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}