use crate::config::Config;
use crate::finding::{Finding, ScanResult, Severity};
use crate::scanners::{which_exists, RuleInfo, Scanner};
use std::path::Path;
use std::time::{Duration, Instant};
const SEMGREP_TIMEOUT: Duration = Duration::from_secs(30);
pub struct SemgrepScanner;
impl Scanner for SemgrepScanner {
fn name(&self) -> &'static str {
"semgrep"
}
fn description(&self) -> &'static str {
"Static analysis via semgrep (external tool)"
}
fn is_available(&self) -> bool {
which_exists("semgrep")
}
fn scan(&self, path: &Path, config: &Config) -> ScanResult {
let start = Instant::now();
let mut cmd = std::process::Command::new("semgrep");
cmd.arg("scan").arg("--json").arg("--quiet");
if let Some(ref custom_config) = config.semgrep.config {
let is_registry = custom_config.starts_with("p/")
|| custom_config.starts_with("r/")
|| custom_config == "auto";
let is_url = custom_config.contains("://");
let is_local_path = Path::new(custom_config.as_str()).exists();
if is_registry || is_url || is_local_path {
cmd.arg("--config").arg(custom_config);
} else {
eprintln!(
"Warning: semgrep config '{}' is not a registry shorthand, URL, or existing \
path — skipping custom config",
custom_config
);
}
} else if Path::new("semgrep.yml").exists() {
cmd.arg("--config").arg("semgrep.yml");
} else if Path::new(".semgrep.yml").exists() {
cmd.arg("--config").arg(".semgrep.yml");
}
if !config.semgrep.metrics {
cmd.arg("--metrics=off");
}
if !config.semgrep.version_check {
cmd.env("SEMGREP_ENABLE_VERSION_CHECK", "0");
}
cmd.arg(path);
let output =
match crate::scanners::run_with_timeout(cmd, SEMGREP_TIMEOUT, self.name(), start) {
Ok(o) => o,
Err(scan_result) => return scan_result,
};
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return ScanResult {
scanner_name: self.name().to_string(),
findings: vec![],
files_scanned: 0,
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
};
}
let root: serde_json::Value = match serde_json::from_str(&stdout) {
Ok(v) => v,
Err(e) => {
return ScanResult::error(
self.name(),
format!("Failed to parse semgrep JSON: {}", e),
start.elapsed().as_millis() as u64,
);
}
};
let results = match root["results"].as_array() {
Some(r) => r,
None => {
return ScanResult {
scanner_name: self.name().to_string(),
findings: vec![],
files_scanned: 0,
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
};
}
};
let total_files_stat = root["stats"]["total_files"].as_u64().map(|n| n as usize);
let mut findings = Vec::new();
for item in results {
let check_id = item["check_id"].as_str().unwrap_or("unknown").to_string();
let rule_id = format!("semgrep/{}", check_id);
let sev_str = item["extra"]["severity"].as_str().unwrap_or("WARNING");
let severity = if sev_str.eq_ignore_ascii_case("ERROR") {
Severity::Error
} else if sev_str.eq_ignore_ascii_case("WARNING") {
Severity::Warning
} else {
Severity::Info
};
let message = item["extra"]["message"]
.as_str()
.unwrap_or("semgrep finding")
.to_string();
let file_path = item["path"].as_str().map(std::path::PathBuf::from);
let line = item["start"]["line"].as_u64().map(|l| l as usize);
let column = item["start"]["col"].as_u64().map(|c| c as usize);
let snippet = item["extra"]["lines"]
.as_str()
.map(|s| s.trim().to_string());
let remediation = item["extra"]["metadata"]["fix"]
.as_str()
.or_else(|| item["extra"]["fix"].as_str())
.map(|s| s.to_string());
findings.push(Finding {
rule_id,
message,
severity,
file: file_path,
line,
column,
scanner: self.name().to_string(),
snippet,
suppressed: false,
suppression_reason: None,
remediation,
});
}
let files_scanned = total_files_stat.unwrap_or_else(|| {
use std::collections::HashSet;
findings
.iter()
.filter_map(|f| f.file.as_ref())
.collect::<HashSet<_>>()
.len()
});
ScanResult {
scanner_name: self.name().to_string(),
findings,
files_scanned,
skipped: false,
skip_reason: None,
error: None,
duration_ms: start.elapsed().as_millis() as u64,
scanner_score: None,
scanner_grade: None,
}
}
}
pub fn rules() -> Vec<RuleInfo> {
vec![
RuleInfo {
id: "semgrep/javascript.express.security.audit.xss.direct-response-write.direct-response-write",
severity: "error",
scanner: "semgrep",
message: "Direct response write (XSS vulnerability)",
remediation: "Escape output or use a templating engine",
},
RuleInfo {
id: "semgrep/python.lang.security.audit.dangerous-spawn-process.dangerous-spawn-process",
severity: "error",
scanner: "semgrep",
message: "Dangerous process spawn (Command Injection)",
remediation: "Use subprocess with a list of arguments instead of shell=True",
},
RuleInfo {
id: "semgrep/bash.curl.security.curl-pipe-bash.curl-pipe-bash",
severity: "error",
scanner: "semgrep",
message: "Curl piped to bash",
remediation: "Download, verify, then execute",
},
]
}