use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use clap::Args;
use colored::Colorize;
use serde_json::Value;
use tldr_core::fs::{read_to_string_tolerant, ReadOutcome};
use tldr_core::walker::ProjectWalker;
use tldr_core::Language;
use tree_sitter::Node;
use crate::output::OutputFormat;
use super::ast_cache::AstCache;
use super::error::{RemainingError, RemainingResult};
use super::types::{SecureFinding, SecureReport, SecureSummary};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecurityAnalysis {
Taint,
Resources,
Bounds,
Contracts,
Behavioral,
Mutability,
}
impl SecurityAnalysis {
pub fn name(&self) -> &'static str {
match self {
Self::Taint => "taint",
Self::Resources => "resources",
Self::Bounds => "bounds",
Self::Contracts => "contracts",
Self::Behavioral => "behavioral",
Self::Mutability => "mutability",
}
}
}
pub const QUICK_ANALYSES: &[SecurityAnalysis] = &[
SecurityAnalysis::Taint,
SecurityAnalysis::Resources,
SecurityAnalysis::Bounds,
];
pub const FULL_ANALYSES: &[SecurityAnalysis] = &[
SecurityAnalysis::Taint,
SecurityAnalysis::Resources,
SecurityAnalysis::Bounds,
SecurityAnalysis::Contracts,
SecurityAnalysis::Behavioral,
SecurityAnalysis::Mutability,
];
#[derive(Debug, Args, Clone)]
pub struct SecureArgs {
pub path: PathBuf,
#[arg(long, short = 'l')]
pub lang: Option<Language>,
#[arg(long)]
pub detail: Option<String>,
#[arg(long)]
pub quick: bool,
#[arg(long, short = 'o')]
pub output: Option<PathBuf>,
#[arg(long)]
pub no_default_ignore: bool,
#[arg(long)]
pub include_tests: bool,
}
impl SecureArgs {
pub fn run(&self, format: OutputFormat) -> anyhow::Result<()> {
run(self.clone(), format)
}
}
pub fn run(args: SecureArgs, format: OutputFormat) -> anyhow::Result<()> {
let start = Instant::now();
if !args.path.exists() {
return Err(RemainingError::file_not_found(&args.path).into());
}
let mut report = SecureReport::new(args.path.display().to_string());
let mut cache = AstCache::default();
let analyses = if args.quick {
QUICK_ANALYSES
} else {
FULL_ANALYSES
};
let effective_lang: Option<Language> = match args.lang {
Some(l) => Some(l),
None => {
let detected = if args.path.is_dir() {
Language::from_directory(&args.path)
} else {
Language::from_path(&args.path)
};
if let Some(l) = detected {
if !super::vuln::is_natively_analyzed(l) {
return Err(RemainingError::autodetect_unsupported(format!(
"secure: taint analysis for {lang} is not yet supported by autodetect; \
pass --lang {lang} explicitly to scan this file (the canonical taint \
pipeline supports it). Autodetect-by-extension currently routes only \
--lang python, --lang rust, --lang typescript, and --lang javascript; \
other languages require an explicit --lang flag.",
lang = l.as_str()
))
.into());
}
}
detected
}
};
let candidate_files = collect_files(&args.path, effective_lang, args.no_default_ignore)?;
let (files, warnings, files_skipped) = partition_utf8_clean(&candidate_files);
let mut all_findings = Vec::new();
let mut sub_results: HashMap<String, Value> = HashMap::new();
for analysis in analyses {
let (findings, raw_result) = run_security_analysis(*analysis, &files, &mut cache)?;
all_findings.extend(findings);
if args.detail.as_deref() == Some(analysis.name()) {
sub_results.insert(analysis.name().to_string(), raw_result);
}
}
if !args.include_tests {
apply_test_file_suppression(&mut all_findings);
}
all_findings.sort_by(|a, b| severity_order(&a.severity).cmp(&severity_order(&b.severity)));
report.summary = compute_summary_from_findings(&all_findings);
report.findings = all_findings;
report.sub_results = sub_results;
report.total_elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
report.files_skipped = files_skipped;
report.warnings = warnings;
let output_str = match format {
OutputFormat::Json => serde_json::to_string_pretty(&report)?,
OutputFormat::Compact => serde_json::to_string(&report)?,
OutputFormat::Text => format_text_report(&report),
OutputFormat::Sarif | OutputFormat::Dot => {
serde_json::to_string_pretty(&report)?
}
};
if let Some(output_path) = &args.output {
fs::write(output_path, &output_str)?;
} else {
println!("{}", output_str);
}
Ok(())
}
fn collect_files(
path: &Path,
lang: Option<Language>,
no_default_ignore: bool,
) -> RemainingResult<Vec<PathBuf>> {
let mut files = Vec::new();
if path.is_file() {
if is_supported_secure_file(path, lang) {
files.push(path.to_path_buf());
}
} else if path.is_dir() {
let mut walker = ProjectWalker::new(path).max_depth(10);
if no_default_ignore {
walker = walker.no_default_ignore();
}
for entry in walker.iter() {
let p = entry.path();
if p.is_file() && is_supported_secure_file(p, lang) {
files.push(p.to_path_buf());
}
}
}
Ok(files)
}
fn is_supported_secure_file(path: &std::path::Path, lang: Option<Language>) -> bool {
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) => e,
None => return false,
};
match lang {
Some(Language::TypeScript) => matches!(ext, "ts" | "tsx"),
Some(Language::JavaScript) => matches!(ext, "js" | "mjs" | "cjs" | "jsx"),
Some(Language::Python) => ext == "py",
Some(Language::Rust) => ext == "rs",
Some(Language::Go) => ext == "go",
Some(Language::Java) => ext == "java",
Some(Language::C) => matches!(ext, "c" | "h"),
Some(Language::Cpp) => matches!(ext, "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx"),
Some(Language::CSharp) => ext == "cs",
Some(Language::Ruby) => ext == "rb",
Some(Language::Php) => ext == "php",
Some(Language::Kotlin) => matches!(ext, "kt" | "kts"),
Some(Language::Swift) => ext == "swift",
Some(Language::Scala) => ext == "scala",
Some(Language::Elixir) => matches!(ext, "ex" | "exs"),
Some(Language::Lua) => ext == "lua",
Some(Language::Luau) => ext == "luau",
Some(Language::Ocaml) => matches!(ext, "ml" | "mli"),
None => matches!(ext, "py" | "rs"),
}
}
fn is_rust_file(path: &std::path::Path) -> bool {
matches!(path.extension().and_then(|e| e.to_str()), Some("rs"))
}
fn partition_utf8_clean(candidates: &[PathBuf]) -> (Vec<PathBuf>, Vec<String>, u32) {
use tldr_core::fs::oversize::{check_size, format_oversize_warning, SizeCheck};
let mut clean: Vec<PathBuf> = Vec::with_capacity(candidates.len());
let mut warnings: Vec<String> = Vec::new();
let mut skipped: u32 = 0;
for file in candidates {
match check_size(file) {
SizeCheck::Oversize {
size_bytes,
max_bytes,
is_autogen,
} => {
skipped += 1;
warnings.push(format_oversize_warning(
file,
size_bytes,
max_bytes,
is_autogen,
));
continue;
}
_ => {}
}
match read_to_string_tolerant(file) {
Ok(ReadOutcome::Ok(_)) => clean.push(file.clone()),
Ok(ReadOutcome::NonUtf8 { byte_offset }) => {
skipped += 1;
warnings.push(format!(
"Skipped {}: invalid UTF-8 at byte {}",
file.display(),
byte_offset
));
}
Err(e) => {
warnings.push(format!(
"Skipped {}: I/O error: {}",
file.display(),
e
));
}
}
}
(clean, warnings, skipped)
}
fn run_security_analysis(
analysis: SecurityAnalysis,
files: &[PathBuf],
cache: &mut AstCache,
) -> RemainingResult<(Vec<SecureFinding>, Value)> {
let mut findings = Vec::new();
for file in files {
let source = match read_to_string_tolerant(file)? {
ReadOutcome::Ok(s) => s,
ReadOutcome::NonUtf8 { .. } => continue,
};
let tree = cache.get_or_parse(file, &source)?;
let file_findings = match analysis {
SecurityAnalysis::Taint => analyze_taint(tree.root_node(), &source, file),
SecurityAnalysis::Resources => analyze_resources(tree.root_node(), &source, file),
SecurityAnalysis::Bounds => analyze_bounds(tree.root_node(), &source, file),
SecurityAnalysis::Contracts => analyze_contracts(tree.root_node(), &source, file),
SecurityAnalysis::Behavioral => analyze_behavioral(tree.root_node(), &source, file),
SecurityAnalysis::Mutability => analyze_mutability(tree.root_node(), &source, file),
};
findings.extend(file_findings);
}
let raw_result = serde_json::to_value(&findings).unwrap_or(Value::Array(vec![]));
Ok((findings, raw_result))
}
fn apply_test_file_suppression(findings: &mut Vec<SecureFinding>) {
findings.retain(|f| {
let p = std::path::Path::new(&f.file);
let in_fixtures =
f.file.contains("/fixtures/") || f.file.contains("\\fixtures\\");
if in_fixtures {
return true;
}
!super::vuln::is_js_test_file(p) && !super::vuln::is_rust_test_file(p)
});
}
fn compute_summary_from_findings(findings: &[SecureFinding]) -> SecureSummary {
let count_cat = |cat: &str| findings.iter().filter(|f| f.category == cat).count() as u32;
SecureSummary {
taint_count: count_cat("taint"),
taint_critical: findings
.iter()
.filter(|f| f.category == "taint" && f.severity == "critical")
.count() as u32,
leak_count: count_cat("resource_leak"),
bounds_warnings: count_cat("bounds"),
behavioral_count: count_cat("behavioral"),
missing_contracts: count_cat("missing_contract"),
mutable_params: count_cat("mutable_param"),
unsafe_blocks: count_cat("unsafe_block"),
raw_pointer_ops: count_cat("raw_pointer"),
unwrap_calls: count_cat("unwrap"),
todo_markers: count_cat("todo_marker"),
}
}
fn severity_order(severity: &str) -> u8 {
match severity {
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
"info" => 4,
_ => 5,
}
}
fn analyze_taint(_root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
let (mut findings, canonical_lines) = canonical_taint_findings_with_index(file);
if is_rust_file(file) {
findings.extend(rust_line_scanner_taint_findings(
file,
source,
&canonical_lines,
));
findings.extend(analyze_rust_unsafe_blocks(source, file));
}
findings
}
fn rust_line_scanner_taint_findings(
file: &Path,
source: &str,
canonical_index: &[(u32, tldr_core::security::vuln::VulnType)],
) -> Vec<SecureFinding> {
use crate::commands::remaining::types::VulnType;
super::vuln::analyze_rust_file(file, source)
.into_iter()
.filter(|f| {
matches!(
f.vuln_type,
VulnType::SqlInjection | VulnType::CommandInjection
)
})
.filter(|f| {
let core_ty = match f.vuln_type {
VulnType::SqlInjection => tldr_core::security::vuln::VulnType::SqlInjection,
VulnType::CommandInjection => {
tldr_core::security::vuln::VulnType::CommandInjection
}
_ => return true,
};
!canonical_index
.iter()
.any(|(line, ty)| *line == f.line && *ty == core_ty)
})
.map(|f| {
let severity = match f.severity {
crate::commands::remaining::types::Severity::Critical => "critical",
crate::commands::remaining::types::Severity::High => "high",
crate::commands::remaining::types::Severity::Medium => "medium",
crate::commands::remaining::types::Severity::Low => "low",
_ => "medium",
};
let description = format!("{:?}: {}", f.vuln_type, f.description);
SecureFinding::new("taint", severity, description).with_location(f.file, f.line)
})
.collect()
}
fn canonical_taint_findings_with_index(
file: &Path,
) -> (
Vec<SecureFinding>,
Vec<(u32, tldr_core::security::vuln::VulnType)>,
) {
let report = match tldr_core::security::vuln::scan_vulnerabilities(file, None, None) {
Ok(r) => r,
Err(_) => return (Vec::new(), Vec::new()),
};
let index: Vec<(u32, tldr_core::security::vuln::VulnType)> = report
.findings
.iter()
.map(|f| (f.sink.line, f.vuln_type))
.collect();
let findings = report
.findings
.into_iter()
.map(|f| {
let severity = match f.severity.to_uppercase().as_str() {
"CRITICAL" => "critical",
"HIGH" => "high",
"MEDIUM" => "medium",
"LOW" => "low",
_ => "medium",
};
let description = format!(
"{:?}: {} with unsanitized input from {}",
f.vuln_type, f.sink.sink_type, f.source.source_type
);
SecureFinding::new("taint", severity, description)
.with_location(f.file.display().to_string(), f.sink.line)
})
.collect();
(findings, index)
}
const RESOURCE_CREATORS: &[&str] = &["open", "socket", "connect", "cursor", "urlopen"];
fn analyze_resources(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
if is_rust_file(file) {
return analyze_rust_raw_pointers(source, file);
}
let mut findings = Vec::new();
let source_bytes = source.as_bytes();
find_leaked_resources(root, source_bytes, file, &mut findings);
findings
}
fn find_leaked_resources(
node: Node,
source: &[u8],
file: &Path,
findings: &mut Vec<SecureFinding>,
) {
if node.kind() == "assignment" {
if let Some(right) = node.child_by_field_name("right") {
if right.kind() == "call" {
if let Some(func) = right.child_by_field_name("function") {
let func_text = node_text(func, source);
let func_name = func_text.split('.').next_back().unwrap_or(func_text);
if RESOURCE_CREATORS.contains(&func_name) {
if !is_inside_with(node) {
findings.push(
SecureFinding::new(
"resource_leak",
"high",
format!(
"Resource '{}' opened without context manager - may leak",
func_name
),
)
.with_location(
file.display().to_string(),
node.start_position().row as u32 + 1,
),
);
}
}
}
}
}
}
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
find_leaked_resources(child, source, file, findings);
}
}
}
fn is_inside_with(node: Node) -> bool {
let mut current = node.parent();
while let Some(parent) = current {
if parent.kind() == "with_statement" {
return true;
}
current = parent.parent();
}
false
}
fn analyze_bounds(_root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
if is_rust_file(file) {
return analyze_rust_bounds(source, file);
}
Vec::new()
}
fn analyze_contracts(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
Vec::new()
}
fn analyze_behavioral(root: Node, source: &str, file: &Path) -> Vec<SecureFinding> {
let mut findings = Vec::new();
let source_bytes = source.as_bytes();
find_bare_except(root, source_bytes, file, &mut findings);
findings
}
fn find_bare_except(node: Node, source: &[u8], file: &Path, findings: &mut Vec<SecureFinding>) {
if node.kind() == "except_clause" {
let has_type = node.children(&mut node.walk()).any(|c| {
c.kind() == "as_pattern"
|| (c.kind() == "identifier" && node_text(c, source) != "Exception")
});
if !has_type {
let text = node_text(node, source);
if text.starts_with("except:") || text.starts_with("except :") {
findings.push(
SecureFinding::new(
"behavioral",
"medium",
"Bare except clause catches all exceptions including KeyboardInterrupt",
)
.with_location(
file.display().to_string(),
node.start_position().row as u32 + 1,
),
);
}
}
}
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
find_bare_except(child, source, file, findings);
}
}
}
fn analyze_mutability(_root: Node, _source: &str, _file: &Path) -> Vec<SecureFinding> {
Vec::new()
}
fn node_text<'a>(node: Node, source: &'a [u8]) -> &'a str {
std::str::from_utf8(&source[node.start_byte()..node.end_byte()]).unwrap_or("")
}
fn analyze_rust_unsafe_blocks(source: &str, file: &Path) -> Vec<SecureFinding> {
let mut findings = Vec::new();
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if trimmed.contains("unsafe {") || trimmed.starts_with("unsafe{") {
findings.push(
SecureFinding::new(
"unsafe_block",
"high",
"unsafe block detected; verify invariants and safety rationale",
)
.with_location(file.display().to_string(), (idx + 1) as u32),
);
}
}
findings
}
fn analyze_rust_raw_pointers(source: &str, file: &Path) -> Vec<SecureFinding> {
let mut findings = Vec::new();
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if trimmed.contains("std::ptr::")
|| trimmed.contains("core::ptr::")
|| trimmed.contains("ptr::read(")
|| trimmed.contains("ptr::write(")
{
findings.push(
SecureFinding::new(
"raw_pointer",
"high",
"raw pointer operation detected; audit aliasing, lifetime, and bounds assumptions",
)
.with_location(file.display().to_string(), (idx + 1) as u32),
);
}
}
findings
}
fn analyze_rust_bounds(source: &str, file: &Path) -> Vec<SecureFinding> {
let mut findings = Vec::new();
let skip_test_only = super::vuln::is_rust_test_file(file);
for (idx, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
if !skip_test_only && trimmed.contains(".unwrap()") {
findings.push(
SecureFinding::new(
"unwrap",
"medium",
"unwrap() call in non-test code may panic at runtime",
)
.with_location(file.display().to_string(), (idx + 1) as u32),
);
}
if !skip_test_only && (trimmed.contains("todo!(") || trimmed.contains("unimplemented!(")) {
findings.push(
SecureFinding::new(
"todo_marker",
"low",
"todo!/unimplemented! marker found in non-test Rust code",
)
.with_location(file.display().to_string(), (idx + 1) as u32),
);
}
}
findings
}
fn format_text_report(report: &SecureReport) -> String {
let mut output = String::new();
output.push_str(&"=".repeat(60));
output.push('\n');
output.push_str(&format!(
"{}\n",
"SECURE - Security Analysis Dashboard".bold()
));
output.push_str(&"=".repeat(60));
output.push_str("\n\n");
output.push_str(&format!("Path: {}\n\n", report.path));
if report.findings.is_empty() {
output.push_str(&format!("{}\n", "No security issues found.".green()));
} else {
output.push_str(&format!(
"{}\n",
"Severity | Category | Description".bold()
));
output.push_str(&format!("{}\n", "-".repeat(60)));
for finding in &report.findings {
let severity_colored = match finding.severity.as_str() {
"critical" => finding.severity.red().bold().to_string(),
"high" => finding.severity.red().to_string(),
"medium" => finding.severity.yellow().to_string(),
"low" => finding.severity.blue().to_string(),
_ => finding.severity.clone(),
};
output.push_str(&format!(
"{:>8} | {:<14} | {}\n",
severity_colored, finding.category, finding.description
));
if !finding.file.is_empty() {
output.push_str(&format!(
" | | {}:{}\n",
finding.file, finding.line
));
}
}
}
output.push('\n');
output.push_str(&format!("{}\n", "Summary:".bold()));
output.push_str(&format!(
" Taint issues: {} ({} critical)\n",
report.summary.taint_count, report.summary.taint_critical
));
output.push_str(&format!(
" Resource leaks: {}\n",
report.summary.leak_count
));
output.push_str(&format!(
" Bounds warnings: {}\n",
report.summary.bounds_warnings
));
output.push_str(&format!(
" Behavioral: {}\n",
report.summary.behavioral_count
));
output.push_str(&format!(
" Missing contracts: {}\n",
report.summary.missing_contracts
));
output.push_str(&format!(
" Mutable params: {}\n",
report.summary.mutable_params
));
output.push_str(&format!(
" Unsafe blocks: {}\n",
report.summary.unsafe_blocks
));
output.push_str(&format!(
" Raw pointer ops: {}\n",
report.summary.raw_pointer_ops
));
output.push_str(&format!(
" Unwrap calls: {}\n",
report.summary.unwrap_calls
));
output.push_str(&format!(
" Todo markers: {}\n",
report.summary.todo_markers
));
output.push('\n');
output.push_str(&format!("Elapsed: {:.2}ms\n", report.total_elapsed_ms));
output
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use tree_sitter::Parser;
fn create_test_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_secure_args_default() {
let args = SecureArgs {
path: PathBuf::from("test.py"),
lang: None,
detail: None,
quick: false,
output: None,
no_default_ignore: false,
include_tests: false,
};
assert!(!args.quick);
assert!(!args.include_tests);
}
#[test]
fn test_severity_order() {
assert!(severity_order("critical") < severity_order("high"));
assert!(severity_order("high") < severity_order("medium"));
assert!(severity_order("medium") < severity_order("low"));
assert!(severity_order("low") < severity_order("info"));
}
#[test]
fn test_taint_analysis_finds_sql_injection() {
let temp = TempDir::new().unwrap();
let source = r#"
from flask import request
import sqlite3
def query():
user_input = request.args.get("name")
conn = sqlite3.connect("db")
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE name = '" + user_input + "'")
"#;
let path = create_test_file(&temp, "vuln.py", source);
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let findings = analyze_taint(tree.root_node(), source, &path);
assert!(
!findings.is_empty(),
"Should detect SQL injection from request.args -> cursor.execute"
);
assert!(findings.iter().all(|f| f.category == "taint"));
}
#[test]
fn test_secure_taint_count_matches_vuln_findings() {
let temp = TempDir::new().unwrap();
let source = r#"
from flask import request
import subprocess
import sqlite3
def cmd():
user = request.args.get("user")
subprocess.call("echo " + user, shell=True)
def sql():
name = request.args.get("name")
conn = sqlite3.connect("db")
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE name='" + name + "'")
"#;
let path = create_test_file(&temp, "flow.py", source);
let vuln_report =
tldr_core::security::vuln::scan_vulnerabilities(&path, None, None).unwrap();
let vuln_count = vuln_report.findings.len();
assert!(
vuln_count > 0,
"Fixture must produce >=1 canonical finding (got 0 - fixture is wrong)"
);
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let secure_findings = analyze_taint(tree.root_node(), source, &path);
assert_eq!(
secure_findings.len(),
vuln_count,
"secure taint findings must match vuln finding count exactly \
(secure={}, vuln={}). secure uses canonical scan_vulnerabilities \
pipeline.",
secure_findings.len(),
vuln_count
);
assert!(secure_findings.iter().all(|f| f.category == "taint"));
}
#[test]
fn test_secure_taint_count_matches_vuln_rust() {
let temp = TempDir::new().unwrap();
let source = r#"
use std::env;
use std::process::Command;
fn run() {
let user = env::var("USER_INPUT").unwrap();
let output = Command::new("sh").arg("-c").arg(&user).output();
let _ = output;
}
"#;
let path = create_test_file(&temp, "cmd_inj.rs", source);
let vuln_report =
tldr_core::security::vuln::scan_vulnerabilities(&path, None, None).unwrap();
let vuln_count = vuln_report.findings.len();
assert!(
vuln_count > 0,
"Fixture must produce >=1 canonical Rust finding (got 0 - fixture is wrong)"
);
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let secure_findings = analyze_taint(tree.root_node(), source, &path);
let taint_findings: Vec<_> = secure_findings
.iter()
.filter(|f| f.category == "taint")
.collect();
assert_eq!(
taint_findings.len(),
vuln_count,
"secure taint findings (category=\"taint\") must match vuln \
finding count exactly on Rust (secure_taint={}, vuln={}). \
RUST-SECURE-TAINT-AGGREGATOR-V2 routes Rust through the \
canonical scan_vulnerabilities pipeline, same as tldr vuln.",
taint_findings.len(),
vuln_count
);
}
#[test]
fn test_resource_analysis_finds_leak() {
let source = r#"
def read_file():
f = open("test.txt")
data = f.read()
return data
"#;
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
assert!(!findings.is_empty(), "Should detect resource leak");
}
#[test]
fn test_resource_analysis_no_leak_with_context() {
let source = r#"
def read_file():
with open("test.txt") as f:
data = f.read()
return data
"#;
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let findings = analyze_resources(tree.root_node(), source, &PathBuf::from("test.py"));
assert!(
findings.is_empty(),
"Should not detect leak with context manager"
);
}
#[test]
fn test_collect_files_includes_rust() {
let temp = TempDir::new().unwrap();
create_test_file(&temp, "sample.py", "print('ok')");
create_test_file(&temp, "lib.rs", "fn main() {}");
create_test_file(&temp, "notes.txt", "ignore");
let files = collect_files(temp.path(), None, false).unwrap();
assert!(files.iter().any(|f| f.ends_with("sample.py")));
assert!(files.iter().any(|f| f.ends_with("lib.rs")));
assert!(!files.iter().any(|f| f.ends_with("notes.txt")));
}
#[test]
fn test_rust_secure_metrics_detected() {
let source = r#"
use std::ptr;
fn risky(user: &str) {
unsafe { ptr::write(user.as_ptr() as *mut u8, b'x'); }
let _v = Some(user).unwrap();
todo!("finish hardening");
}
"#;
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_rust::LANGUAGE.into())
.unwrap();
let tree = parser.parse(source, None).unwrap();
let file = PathBuf::from("src/lib.rs");
let taint_findings = analyze_taint(tree.root_node(), source, &file);
let resource_findings = analyze_resources(tree.root_node(), source, &file);
let bounds_findings = analyze_bounds(tree.root_node(), source, &file);
assert!(!taint_findings.is_empty(), "Should count unsafe blocks");
assert!(
!resource_findings.is_empty(),
"Should count raw pointer ops"
);
assert!(
bounds_findings.iter().any(|f| f.category == "unwrap"),
"Should count unwrap calls"
);
assert!(
bounds_findings.iter().any(|f| f.category == "todo_marker"),
"Should count todo markers"
);
}
#[test]
fn test_secure_skips_oversize_files() {
use tldr_core::fs::oversize::MAX_AUTOGEN_FILE_SIZE_BYTES;
let temp = TempDir::new().unwrap();
let mut padded = String::with_capacity(MAX_AUTOGEN_FILE_SIZE_BYTES as usize + 1024);
padded.push_str("export type Generated = {\n");
let line = " member_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: string;\n";
while (padded.len() as u64) < MAX_AUTOGEN_FILE_SIZE_BYTES + 1024 {
padded.push_str(line);
}
padded.push_str("};\n");
let big = create_test_file(&temp, "dom.generated.d.ts", &padded);
let size = std::fs::metadata(&big).unwrap().len();
assert!(
size > MAX_AUTOGEN_FILE_SIZE_BYTES,
"fixture must exceed auto-gen cap (size={}, cap={})",
size,
MAX_AUTOGEN_FILE_SIZE_BYTES
);
let small = create_test_file(
&temp,
"ok.ts",
"export function f(x: string): string { return x; }\n",
);
let (kept, warnings, files_skipped) =
partition_utf8_clean(&[big.clone(), small.clone()]);
assert!(
!kept.iter().any(|p| p == &big),
"oversize .d.ts must be dropped from kept set: kept={:?}",
kept
);
assert!(
kept.iter().any(|p| p == &small),
"small in-policy .ts must be preserved: kept={:?}",
kept
);
assert_eq!(
files_skipped, 1,
"files_skipped must count the oversize drop (got {})",
files_skipped
);
let oversize_warning = warnings
.iter()
.find(|w| w.contains("dom.generated.d.ts"))
.expect("must emit a warning for the oversize file");
assert!(
oversize_warning.contains("exceeds")
&& oversize_warning.contains("cap for")
&& oversize_warning.contains("auto-generated/minified files"),
"oversize warning must use the format_oversize_warning shape \
(got: {})",
oversize_warning
);
}
fn run_secure_to_json(
path: &Path,
lang: Language,
include_tests: bool,
) -> serde_json::Value {
let temp_out = TempDir::new().unwrap();
let out_path = temp_out.path().join("report.json");
let args = SecureArgs {
path: path.to_path_buf(),
lang: Some(lang),
detail: None,
quick: true,
output: Some(out_path.clone()),
no_default_ignore: false,
include_tests,
};
run(args, OutputFormat::Json).expect("secure::run should succeed");
let raw = fs::read_to_string(&out_path).expect("report file must exist");
serde_json::from_str(&raw).expect("report must be valid JSON")
}
#[test]
fn test_secure_default_suppresses_js_test_files() {
let temp = TempDir::new().unwrap();
let source_js = r#"export function handler(req, res, db) {
const name = req.query.name;
res.send("<h1>" + name + "</h1>");
}
"#;
let test_js = r#"export function handler(req, res, db) {
const input = req.query.q;
res.send("<p>" + input + "</p>");
}
"#;
let src_dir = temp.path().join("src");
let test_dir = temp.path().join("test");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&test_dir).unwrap();
let src_path = src_dir.join("index.js");
let test_path = test_dir.join("app.test.js");
fs::write(&src_path, source_js).unwrap();
fs::write(&test_path, test_js).unwrap();
let report = run_secure_to_json(temp.path(), Language::JavaScript, false);
let findings = report["findings"]
.as_array()
.expect("findings must be an array")
.iter()
.filter(|f| f["category"].as_str() == Some("taint"))
.collect::<Vec<_>>();
assert!(
!findings.is_empty(),
"fixture must produce at least one taint finding (got 0 — fixture is wrong)"
);
let unique_files: std::collections::HashSet<&str> = findings
.iter()
.filter_map(|f| f["file"].as_str())
.collect();
assert_eq!(
unique_files.len(),
1,
"default scan must suppress test-file findings — expected exactly 1 \
unique file (the source), got {:?}",
unique_files
);
let kept_file = unique_files.iter().next().unwrap();
assert!(
kept_file.ends_with("index.js"),
"kept finding must come from the source file, got {:?}",
kept_file
);
assert!(
!kept_file.contains("/test/"),
"kept finding must not come from a test path, got {:?}",
kept_file
);
assert!(
!findings
.iter()
.any(|f| f["file"].as_str().unwrap_or("").contains("/test/")),
"no finding may originate from a test/ path; got: {:?}",
findings.iter().map(|f| f["file"].clone()).collect::<Vec<_>>()
);
}
#[test]
fn test_secure_include_tests_emits_test_findings() {
let temp = TempDir::new().unwrap();
let source_js = r#"export function handler(req, res, db) {
const name = req.query.name;
res.send("<h1>" + name + "</h1>");
}
"#;
let test_js = r#"export function handler(req, res, db) {
const input = req.query.q;
res.send("<p>" + input + "</p>");
}
"#;
let src_dir = temp.path().join("src");
let test_dir = temp.path().join("test");
fs::create_dir_all(&src_dir).unwrap();
fs::create_dir_all(&test_dir).unwrap();
fs::write(src_dir.join("index.js"), source_js).unwrap();
fs::write(test_dir.join("app.test.js"), test_js).unwrap();
let report = run_secure_to_json(temp.path(), Language::JavaScript, true);
let findings = report["findings"]
.as_array()
.expect("findings must be an array")
.iter()
.filter(|f| f["category"].as_str() == Some("taint"))
.collect::<Vec<_>>();
let unique_files: std::collections::HashSet<&str> = findings
.iter()
.filter_map(|f| f["file"].as_str())
.collect();
assert_eq!(
unique_files.len(),
2,
"--include-tests must restore test-file emissions — expected 2 \
unique files (source + test), got {:?}",
unique_files
);
assert!(
unique_files.iter().any(|f| f.ends_with("index.js")),
"must include source-file finding: {:?}",
unique_files
);
assert!(
unique_files.iter().any(|f| f.contains("/test/") && f.ends_with(".test.js")),
"must include test-file finding when --include-tests: {:?}",
unique_files
);
}
#[test]
fn test_apply_test_file_suppression_filters_js_and_rust_test_paths() {
let mk = |file: &str| SecureFinding::new("taint", "high", "x").with_location(file, 1);
let mut findings = vec![
mk("/abs/src/index.js"), mk("/abs/test/app.test.js"), mk("/abs/lib/foo.spec.ts"), mk("/abs/__tests__/x.tsx"), mk("/abs/crates/foo/tests/it.rs"), mk("/abs/crates/foo/src/lib.rs"), mk("/abs/crates/foo/src/foo_test.rs"), mk("/abs/crates/tldr-cli/tests/fixtures/vuln_migration_v1/javascript/x.js"),
];
apply_test_file_suppression(&mut findings);
let kept: Vec<_> = findings.iter().map(|f| f.file.clone()).collect();
assert_eq!(
kept.len(),
3,
"expected 3 kept (2 source + 1 fixture), got {:?}",
kept
);
assert!(kept.iter().any(|f| f.ends_with("/src/index.js")));
assert!(kept.iter().any(|f| f.ends_with("/src/lib.rs")));
assert!(kept.iter().any(|f| f.contains("/fixtures/")));
assert!(!kept.iter().any(|f| f.ends_with("/app.test.js")));
assert!(!kept.iter().any(|f| f.ends_with("/foo.spec.ts")));
assert!(!kept.iter().any(|f| f.ends_with("/__tests__/x.tsx")));
assert!(!kept.iter().any(|f| f.ends_with("/tests/it.rs")));
assert!(!kept.iter().any(|f| f.ends_with("/foo_test.rs")));
}
}