use crate::core::{Confidence, Finding, Severity};
use crate::plugins::traits::{PluginError, PluginReport, ScanContext, ScanPhase, SecurityPlugin};
use async_trait::async_trait;
use serde::Deserialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Instant;
use tokio::process::Command;
#[derive(Debug, Deserialize)]
struct ExternalFinding {
id: String,
title: String,
description: Option<String>,
severity: String,
#[serde(default)]
confidence: Option<String>,
file_path: Option<String>,
line_start: Option<usize>,
line_end: Option<usize>,
evidence: Option<String>,
remediation: Option<String>,
#[serde(default)]
references: Vec<String>,
#[serde(default)]
cwe_ids: Vec<u32>,
}
#[derive(Debug, Deserialize)]
struct ExternalPluginOutput {
#[serde(default)]
#[allow(dead_code)]
plugin_name: String,
#[serde(default)]
#[allow(dead_code)]
version: Option<String>,
findings: Vec<ExternalFinding>,
#[serde(default)]
scanned_files: Option<usize>,
#[serde(default)]
duration_ms: Option<u64>,
}
pub struct ExternalPlugin {
name: String,
executable_path: PathBuf,
version: String,
description: String,
scan_phase: ScanPhase,
}
impl ExternalPlugin {
pub fn new(
name: String,
executable_path: PathBuf,
description: String,
scan_phase: ScanPhase,
) -> Self {
Self {
name,
executable_path,
version: "1.0.0".to_string(),
description,
scan_phase,
}
}
fn parse_severity(severity_str: &str) -> Severity {
match severity_str.to_lowercase().as_str() {
"critical" => Severity::Critical,
"high" => Severity::High,
"medium" => Severity::Medium,
"low" => Severity::Low,
"info" => Severity::Info,
_ => Severity::Medium,
}
}
fn parse_confidence(confidence_str: Option<String>) -> Confidence {
match confidence_str.as_deref() {
Some("high") => Confidence::High,
Some("medium") => Confidence::Medium,
Some("low") => Confidence::Low,
_ => Confidence::Medium,
}
}
fn convert_finding(&self, external: ExternalFinding, file_path: &std::path::Path) -> Finding {
let mut finding = Finding::new(
external.id,
external.title,
Self::parse_severity(&external.severity),
);
if let Some(desc) = external.description {
finding = finding.with_description(desc);
}
finding.confidence = Self::parse_confidence(external.confidence);
if let Some(path_str) = external.file_path {
finding.file_path = Some(PathBuf::from(path_str));
} else {
finding.file_path = Some(file_path.to_path_buf());
}
finding.line_start = external.line_start.map(|l| l as u32);
finding.line_end = external.line_end.map(|l| l as u32);
if let Some(evidence) = external.evidence {
finding.evidence.push(evidence);
}
if let Some(remediation) = external.remediation {
finding.remediation = Some(remediation);
}
finding.references = external.references;
finding.cwe_ids = external.cwe_ids;
finding
}
}
#[async_trait]
impl SecurityPlugin for ExternalPlugin {
fn name(&self) -> &str {
&self.name
}
fn version(&self) -> &str {
&self.version
}
fn description(&self) -> &str {
&self.description
}
fn scan_phase(&self) -> ScanPhase {
self.scan_phase
}
async fn initialize(&mut self) -> Result<(), PluginError> {
if !self.executable_path.exists() {
return Err(PluginError::InitializationFailed(format!(
"Plugin executable not found: {}",
self.executable_path.display()
)));
}
if !self.executable_path.is_file() {
return Err(PluginError::InitializationFailed(format!(
"Plugin path is not a file: {}",
self.executable_path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(&self.executable_path)
.map_err(|e| PluginError::InitializationFailed(e.to_string()))?;
let permissions = metadata.permissions();
if permissions.mode() & 0o111 == 0 {
return Err(PluginError::InitializationFailed(format!(
"Plugin is not executable: {}",
self.executable_path.display()
)));
}
}
Ok(())
}
async fn scan(&self, context: &ScanContext<'_>) -> Result<PluginReport, PluginError> {
let start = Instant::now();
let path_str = context
.path
.to_str()
.ok_or_else(|| PluginError::ScanFailed("Invalid path encoding".to_string()))?;
let child = Command::new(&self.executable_path)
.arg(path_str)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
PluginError::ScanFailed(format!("Failed to spawn plugin process: {}", e))
})?;
let output = child
.wait_with_output()
.await
.map_err(|e| PluginError::ScanFailed(format!("Failed to read plugin output: {}", e)))?;
if !output.status.success() && output.stdout.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PluginError::ScanFailed(format!(
"Plugin exited with error: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let external_output: ExternalPluginOutput = serde_json::from_str(&stdout).map_err(|e| {
PluginError::ScanFailed(format!(
"Failed to parse plugin output as JSON: {} - Output: {}",
e, stdout
))
})?;
let findings: Vec<Finding> = external_output
.findings
.into_iter()
.map(|f| self.convert_finding(f, context.path))
.collect();
let duration_ms = start.elapsed().as_millis() as u64;
let mut report = PluginReport::new(self.name.clone());
report.findings = findings;
report.scanned_files = external_output.scanned_files.unwrap_or(1);
report.duration_ms = external_output.duration_ms.unwrap_or(duration_ms);
Ok(report)
}
}