use crate::types::{Ecosystem, Finding, FindingKind, Severity};
use anyhow::{Context, Result};
use colored::Colorize;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
struct ToolRun {
stdout: String,
stderr: String,
exit_code: i32,
}
fn run_command(
binary: &str,
args: &[&str],
working_dir: &Path,
timeout_secs: u64,
) -> Result<ToolRun> {
let mut cmd = Command::new(binary);
cmd.args(args)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if cfg!(target_os = "windows") {
cmd.env("PYTHONUTF8", "1");
}
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to execute {}", binary))?;
let child_id = child.id();
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_handle = std::thread::spawn(move || {
stdout_pipe
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default()
});
let stderr_handle = std::thread::spawn(move || {
stderr_pipe
.map(|mut s| {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut s, &mut buf).ok();
String::from_utf8_lossy(&buf).to_string()
})
.unwrap_or_default()
});
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = stdout_handle.join().unwrap_or_default();
let stderr = stderr_handle.join().unwrap_or_default();
let exit_code = status.code().unwrap_or(1);
return Ok(ToolRun {
stdout,
stderr,
exit_code,
});
}
Ok(None) => {
if start.elapsed() >= timeout {
child.kill().ok();
child.wait().ok();
anyhow::bail!(
"{} (pid {}) timed out after {} seconds. Check if the tool is hanging or increase tool_timeout_seconds in ai-rsk.config.yaml.",
binary,
child_id,
timeout_secs
);
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
child.kill().ok();
child.wait().ok();
anyhow::bail!("Error waiting for {}: {}", binary, e);
}
}
}
}
const SCAN_EXCLUDED_DIRS: &[&str] = &[
"node_modules",
"vendor",
".git",
"dist",
"build",
"bundle",
"out",
"__pycache__",
".pytest_cache",
"target",
".venv",
"venv",
"env",
".next",
".nuxt",
];
pub fn run_semgrep(
project_path: &Path,
timeout_secs: u64,
exclude_rules: &[String],
) -> Result<Vec<Finding>> {
let mut args: Vec<String> = vec![
"scan".into(),
"--error".into(),
"--config=auto".into(),
"--quiet".into(),
];
for dir in SCAN_EXCLUDED_DIRS {
args.push(format!("--exclude={}", dir));
}
for rule in exclude_rules {
args.push(format!("--exclude-rule={}", rule));
}
args.push(".".into());
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let result = run_command("semgrep", &args_refs, project_path, timeout_secs)?;
match result.exit_code {
0 => Ok(vec![]),
1 => {
let output = if result.stdout.is_empty() {
result.stderr
} else {
result.stdout
};
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "semgrep".to_string(),
output: truncate_output(&output, 2000),
},
file: None,
line: None,
message: "Semgrep found security issues.".to_string(),
}])
}
2 => {
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "semgrep".to_string(),
output: truncate_output(&result.stderr, 1000),
},
file: None,
line: None,
message: "Semgrep crashed or failed to run.".to_string(),
}])
}
_ => {
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "semgrep".to_string(),
output: truncate_output(&result.stderr, 1000),
},
file: None,
line: None,
message: format!(
"Semgrep exited with unexpected code {} - check configuration.",
result.exit_code
),
}])
}
}
}
pub fn run_gitleaks(project_path: &Path, timeout_secs: u64) -> Result<Vec<Finding>> {
let result = run_command(
"gitleaks",
&["detect", "--source", ".", "--no-banner", "--exit-code", "1"],
project_path,
timeout_secs,
)?;
match result.exit_code {
0 => Ok(vec![]),
1 => {
let output = if result.stdout.is_empty() {
result.stderr
} else {
result.stdout
};
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "gitleaks".to_string(),
output: truncate_output(&output, 2000),
},
file: None,
line: None,
message: "Gitleaks found secrets in the codebase.".to_string(),
}])
}
_ => Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "gitleaks".to_string(),
output: truncate_output(&result.stderr, 1000),
},
file: None,
line: None,
message: format!("Gitleaks exited with unexpected code {}.", result.exit_code),
}]),
}
}
pub fn run_osv_scanner(project_path: &Path, timeout_secs: u64) -> Result<Vec<Finding>> {
let result = run_command(
"osv-scanner",
&["scan", "--recursive", "."],
project_path,
timeout_secs,
)?;
match result.exit_code {
0 => Ok(vec![]),
1 => {
let output = if result.stdout.is_empty() {
result.stderr
} else {
result.stdout
};
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "osv-scanner".to_string(),
output: truncate_output(&output, 2000),
},
file: None,
line: None,
message: "osv-scanner found known vulnerabilities in dependencies.".to_string(),
}])
}
128 => {
Ok(vec![])
}
_ => {
Ok(vec![Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "osv-scanner".to_string(),
output: truncate_output(&result.stderr, 1000),
},
file: None,
line: None,
message: format!(
"osv-scanner exited with code {} - possible network or configuration error.",
result.exit_code
),
}])
}
}
}
pub fn run_knip(project_path: &Path, timeout_secs: u64) -> Result<Vec<Finding>> {
let knip_binary = if which::which("knip").is_ok() {
"knip".to_string()
} else {
let local_knip = project_path.join("node_modules/.bin/knip");
if local_knip.exists() {
local_knip.to_string_lossy().to_string()
} else {
return Ok(vec![]);
}
};
let result = run_command(&knip_binary, &["--no-progress"], project_path, timeout_secs)?;
match result.exit_code {
0 => Ok(vec![]),
1 => {
let output = if result.stdout.is_empty() {
result.stderr
} else {
result.stdout
};
Ok(vec![Finding {
severity: Severity::Warn,
kind: FindingKind::ToolFailed {
tool_name: "knip".to_string(),
output: truncate_output(&output, 2000),
},
file: None,
line: None,
message: "knip found dead code, unused dependencies, or unused exports."
.to_string(),
}])
}
_ => Ok(vec![]),
}
}
pub fn knip_available(project_path: &Path) -> bool {
which::which("knip").is_ok() || project_path.join("node_modules/.bin/knip").exists()
}
pub fn run_external_tools(
project_path: &Path,
ecosystems: &[Ecosystem],
timeout_secs: u64,
semgrep_exclude_rules: &[String],
) -> Vec<Finding> {
let mut findings = Vec::new();
match run_semgrep(project_path, timeout_secs, semgrep_exclude_rules) {
Ok(f) => findings.extend(f),
Err(e) => findings.push(Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "semgrep".to_string(),
output: format!("{}", e),
},
file: None,
line: None,
message: format!("Failed to run semgrep: {}", e),
}),
}
match run_gitleaks(project_path, timeout_secs) {
Ok(f) => findings.extend(f),
Err(e) => findings.push(Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "gitleaks".to_string(),
output: format!("{}", e),
},
file: None,
line: None,
message: format!("Failed to run gitleaks: {}", e),
}),
}
match run_osv_scanner(project_path, timeout_secs) {
Ok(f) => findings.extend(f),
Err(e) => findings.push(Finding {
severity: Severity::Block,
kind: FindingKind::ToolFailed {
tool_name: "osv-scanner".to_string(),
output: format!("{}", e),
},
file: None,
line: None,
message: format!("Failed to run osv-scanner: {}", e),
}),
}
if ecosystems.contains(&Ecosystem::JavaScript) {
match run_knip(project_path, timeout_secs) {
Ok(f) => findings.extend(f),
Err(e) => {
eprintln!(" {} knip failed: {}", "!".yellow(), e);
}
}
}
findings
}
fn truncate_output(output: &str, max_chars: usize) -> String {
if output.len() <= max_chars {
output.to_string()
} else {
let mut boundary = max_chars;
while boundary > 0 && !output.is_char_boundary(boundary) {
boundary -= 1;
}
let truncated = &output[..boundary];
format!(
"{}...\n[truncated - {} chars total]",
truncated,
output.len()
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_short_output() {
let output = "short";
assert_eq!(truncate_output(output, 100), "short");
}
#[test]
fn test_truncate_long_output() {
let output = "a".repeat(500);
let result = truncate_output(&output, 100);
assert!(result.contains("[truncated"));
assert!(result.contains("500 chars total"));
}
#[test]
fn test_truncate_exact_length() {
let output = "a".repeat(100);
assert_eq!(truncate_output(&output, 100), output);
}
}