use super::{Check, CheckEligibility, CheckResult, CheckStatus, ProvenanceBuilder, run_command};
use crate::Config;
use crate::git::{Repository, ResolvedRef, git_cmd};
use anyhow::Result;
use async_trait::async_trait;
use chrono::Local;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub struct SemgrepCheck;
#[async_trait]
impl Check for SemgrepCheck {
fn name(&self) -> &str {
"Semgrep scan"
}
fn check_eligibility(&self, _config: &Config) -> CheckEligibility {
if which::which("semgrep").is_ok() {
CheckEligibility::Run
} else {
CheckEligibility::Skip("semgrep not available".to_string())
}
}
fn cache_key(&self, _config: &Config) -> Option<String> {
None
}
async fn run(&self, config: &Config) -> Result<CheckResult> {
let start = std::time::Instant::now();
let started_at = Local::now().to_rfc3339();
let plan = match plan_semgrep_scan(config) {
Ok(plan) => plan,
Err(reason) => {
return Ok(CheckResult {
name: self.name().to_string(),
status: CheckStatus::Skipped,
duration: start.elapsed(),
output: reason,
cached: false,
provenance: None,
});
}
};
let cwd = plan.scan_dir.as_path();
let config_path = cwd.join("semgrep.yml");
let config_arg = if config_path.exists() {
"semgrep.yml"
} else {
"auto"
};
let args = build_semgrep_args(config_arg, plan.baseline_commit.as_deref());
let output = run_command("semgrep", &args, cwd).await?;
let finished_at = Local::now().to_rfc3339();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}\n{}", stdout, stderr);
let status = classify_semgrep_status(output.status.success(), &stdout, &combined);
Ok(CheckResult {
name: self.name().to_string(),
status,
duration: start.elapsed(),
output: combined.clone(),
cached: false,
provenance: Some(
ProvenanceBuilder {
cmd: "semgrep",
args: &args,
cwd,
output: &output,
combined_output: &combined,
started_at: &started_at,
finished_at: &finished_at,
cache_key: None,
}
.build(),
),
})
}
}
fn classify_semgrep_status(command_succeeded: bool, stdout: &str, combined: &str) -> CheckStatus {
if !command_succeeded {
return CheckStatus::Failed;
}
if output_reports_scan_errors(stdout) || combined.contains("warning") {
return CheckStatus::Warnings;
}
CheckStatus::Passed
}
pub(crate) fn output_reports_scan_errors(output: &str) -> bool {
let Some(start) = output.find('{') else {
return false;
};
let mut de = serde_json::Deserializer::from_str(&output[start..]);
let Ok(parsed) = serde_json::Value::deserialize(&mut de) else {
return false;
};
parsed
.get("errors")
.and_then(|errors| errors.as_array())
.is_some_and(|errors| !errors.is_empty())
}
fn build_semgrep_args<'a>(config_arg: &'a str, baseline_commit: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec![
"scan", "--config", config_arg, "--json", "--error", "--quiet",
];
if let Some(commit) = baseline_commit {
args.push("--baseline-commit");
args.push(commit);
}
args.extend([
".",
"--exclude",
"target",
"--exclude",
"node_modules",
"--exclude",
"*.min.js",
"--exclude",
"public_dist",
]);
args
}
struct SemgrepScanPlan {
scan_dir: PathBuf,
baseline_commit: Option<String>,
_snapshot: Option<WorktreeSnapshot>,
}
fn plan_semgrep_scan(config: &Config) -> std::result::Result<SemgrepScanPlan, String> {
let repo_root = config.repo_root.clone();
let Ok(repo) = Repository::open(&repo_root) else {
return Ok(SemgrepScanPlan {
scan_dir: repo_root,
baseline_commit: None,
_snapshot: None,
});
};
let (Ok(target), Ok(head)) = (repo.resolve_target(config), repo.head_commit_id()) else {
return Ok(SemgrepScanPlan {
baseline_commit: semgrep_baseline_commit(config, &repo_root),
scan_dir: repo_root,
_snapshot: None,
});
};
if head == target.commit_id {
return Ok(SemgrepScanPlan {
baseline_commit: semgrep_baseline_commit(config, &repo_root),
scan_dir: repo_root,
_snapshot: None,
});
}
let snapshot = create_worktree_snapshot(&repo_root, &target.commit_id).map_err(|e| {
format!(
"semgrep: could not create an ephemeral worktree for target {} ({e}); \
skipping instead of scanning the local checkout",
short_oid(&target.commit_id),
)
})?;
let baseline = snapshot_baseline_commit(&repo, config, &target);
Ok(SemgrepScanPlan {
scan_dir: snapshot.worktree_path.clone(),
baseline_commit: baseline,
_snapshot: Some(snapshot),
})
}
fn semgrep_baseline_commit(config: &Config, cwd: &Path) -> Option<String> {
let repo = Repository::open(cwd).ok()?;
let target = repo.resolve_target(config).ok()?;
let head = repo.head_commit_id().ok()?;
let target_is_checkout = head == target.commit_id;
let dirty = worktree_has_uncommitted_changes(cwd);
if !baseline_scan_allowed(
config.security_full,
dirty,
target_is_checkout,
config.current_only,
) {
return None;
}
merge_base_for_baseline(&repo, config, &target)
}
fn snapshot_baseline_commit(
repo: &Repository,
config: &Config,
target: &ResolvedRef,
) -> Option<String> {
if !baseline_scan_allowed(config.security_full, false, true, config.current_only) {
return None;
}
merge_base_for_baseline(repo, config, target)
}
fn merge_base_for_baseline(
repo: &Repository,
config: &Config,
target: &ResolvedRef,
) -> Option<String> {
let bases = repo.resolve_bases(config).ok()?;
let [base] = bases.as_slice() else {
return None;
};
if base.commit_id == target.commit_id {
return None;
}
repo.merge_base(&base.commit_id, &target.commit_id).ok()
}
struct WorktreeSnapshot {
repo_root: PathBuf,
worktree_path: PathBuf,
_tmp: tempfile::TempDir,
}
impl Drop for WorktreeSnapshot {
fn drop(&mut self) {
let _ = git_cmd()
.args(["worktree", "remove", "--force"])
.arg(&self.worktree_path)
.current_dir(&self.repo_root)
.output();
let _ = git_cmd()
.args(["worktree", "prune"])
.current_dir(&self.repo_root)
.output();
}
}
fn create_worktree_snapshot(repo_root: &Path, commit: &str) -> Result<WorktreeSnapshot> {
let tmp = tempfile::tempdir()?;
let worktree_path = tmp.path().join("snapshot");
let output = git_cmd()
.args(["worktree", "add", "--detach", "--force"])
.arg(&worktree_path)
.arg(commit)
.current_dir(repo_root)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {}", stderr.trim());
}
Ok(WorktreeSnapshot {
repo_root: repo_root.to_path_buf(),
worktree_path,
_tmp: tmp,
})
}
fn baseline_scan_allowed(
security_full: bool,
worktree_dirty: bool,
target_is_checkout: bool,
current_only: bool,
) -> bool {
!security_full && !worktree_dirty && target_is_checkout && !current_only
}
fn short_oid(id: &str) -> &str {
&id[..id.len().min(8)]
}
fn worktree_has_uncommitted_changes(cwd: &Path) -> bool {
let Ok(repo) = git2::Repository::discover(cwd) else {
return true;
};
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.renames_head_to_index(true);
repo.statuses(Some(&mut opts))
.map(|statuses| !statuses.is_empty())
.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_config;
#[test]
fn build_semgrep_args_excludes_vendored_and_generated_and_emits_json() {
let args = build_semgrep_args("auto", None);
let has_exclude = |val: &str| args.windows(2).any(|w| w[0] == "--exclude" && w[1] == val);
assert!(args.contains(&"--json"), "structured parser expects JSON");
assert!(has_exclude("*.min.js"), "must exclude minified bundles");
assert!(has_exclude("public_dist"), "must exclude generated site");
assert!(has_exclude("node_modules"));
assert!(has_exclude("target"));
assert!(args.contains(&"auto"), "config arg threaded through");
}
#[test]
fn build_semgrep_args_adds_baseline_commit_when_available() {
let args = build_semgrep_args("auto", Some("abc123"));
assert!(
args.windows(2)
.any(|w| w[0] == "--baseline-commit" && w[1] == "abc123"),
"baseline commit must be threaded to semgrep"
);
}
#[test]
fn build_semgrep_args_omits_baseline_without_merge_base() {
let args = build_semgrep_args("auto", None);
assert!(
!args.contains(&"--baseline-commit"),
"full-tree fallback must not pass a bogus baseline"
);
}
#[test]
fn test_semgrep_check_name() {
let check = SemgrepCheck;
assert_eq!(check.name(), "Semgrep scan");
}
#[test]
fn partial_parsing_errors_degrade_to_warnings() {
let stdout = r#"{"version":"1.135.0","results":[],"errors":[{"code":3,"level":"warn","type":["PartialParsing",[]],"message":"Syntax error: `unsafe extern \"C\"` was unexpected"}]}"#;
let combined = format!("{stdout}\n");
assert_eq!(
classify_semgrep_status(true, stdout, &combined),
CheckStatus::Warnings
);
}
#[test]
fn clean_scan_with_no_errors_passes() {
let stdout = r#"{"version":"1.135.0","results":[],"errors":[]}"#;
let combined = format!("{stdout}\n");
assert_eq!(
classify_semgrep_status(true, stdout, &combined),
CheckStatus::Passed
);
}
#[test]
fn non_zero_exit_is_failed() {
let stdout = r#"{"version":"1.135.0","results":[],"errors":[]}"#;
let combined = format!("{stdout}\n");
assert_eq!(
classify_semgrep_status(false, stdout, &combined),
CheckStatus::Failed
);
}
#[test]
fn output_reports_scan_errors_detects_partial_parsing() {
let with_errors = r#"{"results":[],"errors":[{"type":["PartialParsing",[]]}]}"#;
let without_errors = r#"{"results":[],"errors":[]}"#;
assert!(output_reports_scan_errors(with_errors));
assert!(!output_reports_scan_errors(without_errors));
assert!(!output_reports_scan_errors("not json"));
}
#[test]
fn output_reports_scan_errors_tolerates_trailing_stderr() {
let combined = "{\"results\":[],\"errors\":[{\"type\":[\"PartialParsing\",[]]}]}\nsome semgrep stderr noise\n";
assert!(output_reports_scan_errors(combined));
}
#[test]
fn test_semgrep_check_can_run() {
let config = test_config();
let check = SemgrepCheck;
let _ = check.check_eligibility(&config);
}
#[test]
fn baseline_allowed_when_target_is_checkout_and_clean() {
assert!(baseline_scan_allowed(false, false, true, false));
}
#[test]
fn baseline_disallowed_when_target_not_checked_out() {
assert!(!baseline_scan_allowed(false, false, false, false));
}
#[test]
fn baseline_disallowed_when_security_full_or_dirty() {
assert!(!baseline_scan_allowed(true, false, true, false));
assert!(!baseline_scan_allowed(false, true, true, false));
}
#[test]
fn baseline_disallowed_when_current_only() {
assert!(!baseline_scan_allowed(false, false, true, true));
}
fn run_git(repo: &Path, args: &[&str]) {
let status = git_cmd()
.args(args)
.current_dir(repo)
.status()
.expect("git command");
assert!(status.success(), "git {args:?} failed with {status}");
}
fn write_commit(repo: &Path, name: &str, body: &str) -> String {
std::fs::write(repo.join(name), body).expect("write fixture");
run_git(repo, &["add", name]);
run_git(
repo,
&[
"-c",
"user.name=prview test",
"-c",
"user.email=prview@example.test",
"commit",
"-m",
name,
],
);
let output = git_cmd()
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.expect("rev-parse");
assert!(output.status.success());
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn worktree_count(repo: &Path) -> usize {
let output = git_cmd()
.args(["worktree", "list"])
.current_dir(repo)
.output()
.expect("worktree list");
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.trim().is_empty())
.count()
}
#[test]
fn worktree_snapshot_materialises_target_and_cleans_up_on_drop() {
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let earlier = write_commit(tmp.path(), "a.txt", "one\n");
let _head = write_commit(tmp.path(), "b.txt", "two\n");
let worktree_path;
{
let snapshot =
create_worktree_snapshot(tmp.path(), &earlier).expect("snapshot creation");
worktree_path = snapshot.worktree_path.clone();
assert!(snapshot.worktree_path.join("a.txt").exists());
assert!(!snapshot.worktree_path.join("b.txt").exists());
assert_eq!(worktree_count(tmp.path()), 2);
}
assert!(
!worktree_path.exists(),
"worktree dir must be removed on drop"
);
assert_eq!(
worktree_count(tmp.path()),
1,
"worktree must be deregistered on drop"
);
}
#[test]
fn worktree_snapshot_errors_on_unknown_commit_without_leaking() {
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let _initial = write_commit(tmp.path(), "a.txt", "one\n");
let before = worktree_count(tmp.path());
let result =
create_worktree_snapshot(tmp.path(), "0000000000000000000000000000000000000000");
assert!(result.is_err(), "a bogus commit must fail to materialise");
assert_eq!(
worktree_count(tmp.path()),
before,
"a failed worktree add must not leave a registered worktree"
);
}
#[test]
fn merge_base_is_diff_scoped_with_a_single_base() {
use crate::config::{test_config_builder, test_generic_profile};
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let base_commit = write_commit(tmp.path(), "a.txt", "one\n");
run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
let _target = write_commit(tmp.path(), "b.txt", "two\n");
let config = test_config_builder()
.repo_root(tmp.path())
.target(Some("feature"))
.bases(&["main"])
.profile(test_generic_profile())
.build();
let repo = Repository::open(tmp.path()).expect("open repo");
let resolved_target = repo.resolve_target(&config).expect("resolve target");
let baseline = merge_base_for_baseline(&repo, &config, &resolved_target);
assert_eq!(
baseline.as_deref(),
Some(base_commit.as_str()),
"a single resolved base must diff-scope against its merge-base"
);
}
#[test]
fn merge_base_falls_back_to_full_scan_with_multiple_bases() {
use crate::config::{test_config_builder, test_generic_profile};
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let _base_commit = write_commit(tmp.path(), "a.txt", "one\n");
run_git(tmp.path(), &["branch", "develop", "main"]);
run_git(tmp.path(), &["checkout", "-q", "-b", "feature"]);
let _target = write_commit(tmp.path(), "b.txt", "two\n");
let config = test_config_builder()
.repo_root(tmp.path())
.target(Some("feature"))
.bases(&["main", "develop"])
.profile(test_generic_profile())
.build();
let repo = Repository::open(tmp.path()).expect("open repo");
let resolved_target = repo.resolve_target(&config).expect("resolve target");
assert_eq!(
repo.resolve_bases(&config).expect("resolve bases").len(),
2,
"fixture must resolve two bases"
);
assert_eq!(
merge_base_for_baseline(&repo, &config, &resolved_target),
None,
"more than one resolved base must fall back to a full scan (R3-15)"
);
}
#[test]
fn plan_scans_snapshot_when_target_is_not_checked_out() {
use crate::config::{test_config_builder, test_generic_profile};
let tmp = tempfile::tempdir().expect("tempdir");
run_git(tmp.path(), &["init", "-q", "-b", "main"]);
let earlier = write_commit(tmp.path(), "a.txt", "one\n");
let target = write_commit(tmp.path(), "b.txt", "two\n");
run_git(tmp.path(), &["checkout", "-q", &earlier]);
let config = test_config_builder()
.repo_root(tmp.path())
.target(Some(target.as_str()))
.profile(test_generic_profile())
.build();
let plan = plan_semgrep_scan(&config).expect("plan");
assert_ne!(
plan.scan_dir,
tmp.path(),
"a non-checked-out target must scan the snapshot, not the local checkout"
);
assert!(
plan._snapshot.is_some(),
"the scan dir must be backed by an ephemeral snapshot"
);
assert!(plan.scan_dir.join("b.txt").exists());
}
}