Skip to main content

forge_guard/plugins/
mod.rs

1//! Plugin architecture — extensible plugin system for forge-guard.
2//!
3//! Plugins can be either:
4//! - **Built-in**: Rust types implementing the `Plugin` trait, registered at compile time.
5//! - **External**: Standalone binaries or scripts that communicate via a JSON IPC protocol
6//!   (stdin → JSON context, stdout → JSON findings, stderr → logs).
7
8use crate::core::{Finding, ForgeGuardError, ProjectConfig, Severity};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::time::Instant;
13
14// ── Re-export the core trait ─────────────────────────────────────
15
16/// Re-export the Plugin trait for plugin authors.
17pub use Plugin as PluginTrait;
18
19/// Result from a plugin execution.
20pub type PluginResult = std::result::Result<Vec<Finding>, ForgeGuardError>;
21
22/// Context passed to plugins during execution.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PluginContext {
25    /// The current project configuration.
26    pub config: ProjectConfig,
27    /// Source files being analyzed (absolute paths).
28    pub source_files: Vec<PathBuf>,
29    /// Additional metadata (chain name, flags, etc.).
30    #[serde(default)]
31    pub metadata: HashMap<String, String>,
32}
33
34impl PluginContext {
35    /// Create a new plugin context.
36    pub fn new(config: &ProjectConfig, source_files: Vec<PathBuf>) -> Self {
37        Self {
38            config: config.clone(),
39            source_files,
40            metadata: HashMap::new(),
41        }
42    }
43
44    /// Add a metadata key-value pair.
45    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
46        self.metadata.insert(key.into(), value.into());
47        self
48    }
49}
50
51/// Core trait that all built-in plugins must implement.
52pub trait Plugin: Send + Sync {
53    /// Plugin name (e.g., "my-custom-check").
54    fn name(&self) -> &'static str;
55    /// Plugin version.
56    fn version(&self) -> &'static str;
57    /// Human-readable description.
58    fn description(&self) -> &'static str;
59    /// Execute the plugin's analysis logic and return findings.
60    fn execute(&self, ctx: &PluginContext) -> PluginResult;
61    /// Whether this plugin can run in offline mode.
62    fn supports_offline(&self) -> bool {
63        true
64    }
65    /// Whether this plugin requires RPC access.
66    fn requires_rpc(&self) -> bool {
67        false
68    }
69}
70
71// ── IPC Protocol for external (subprocess) plugins ──────────────
72
73/// JSON message sent *to* a plugin binary (stdin).
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct PluginIpcInput {
76    /// Protocol version for forward compatibility.
77    pub protocol_version: String,
78    /// Plugin name as defined in plugin.toml.
79    pub plugin_name: String,
80    /// The analysis context (project, files, metadata).
81    pub context: PluginContext,
82}
83
84/// JSON message expected *from* a plugin binary (stdout).
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PluginIpcOutput {
87    /// Whether execution was successful.
88    pub success: bool,
89    /// Findings discovered by the plugin.
90    #[serde(default)]
91    pub findings: Vec<PluginIpcFinding>,
92    /// Optional error message if not successful.
93    #[serde(default)]
94    pub error: Option<String>,
95    /// Execution statistics.
96    #[serde(default)]
97    pub stats: PluginExecutionStats,
98}
99
100/// A finding as reported by an external plugin.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct PluginIpcFinding {
103    /// Human-readable title.
104    pub title: String,
105    /// Detailed description.
106    pub description: String,
107    /// Severity: "critical", "high", "medium", "low", "informational"
108    pub severity: String,
109    /// File path (relative to project root).
110    #[serde(default)]
111    pub file: Option<String>,
112    /// Line number.
113    #[serde(default)]
114    pub line: Option<usize>,
115    /// Column number.
116    #[serde(default)]
117    pub column: Option<usize>,
118    /// Code snippet.
119    #[serde(default)]
120    pub code_snippet: Option<String>,
121    /// Recommended remediation.
122    #[serde(default)]
123    pub recommendation: Option<String>,
124    /// Category of vulnerability.
125    #[serde(default)]
126    pub category: Option<String>,
127    /// Whether this finding blocks deployment.
128    #[serde(default)]
129    pub blocks_deployment: bool,
130    /// Additional references (CVE IDs, links, etc.).
131    #[serde(default)]
132    pub references: Vec<String>,
133}
134
135/// Execution statistics for a plugin run.
136#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct PluginExecutionStats {
138    /// Number of files analyzed.
139    #[serde(default)]
140    pub files_analyzed: u32,
141    /// Execution time in milliseconds.
142    #[serde(default)]
143    pub duration_ms: u64,
144}
145
146fn parse_plugin_finding(f: &PluginIpcFinding) -> Finding {
147    let severity = match f.severity.to_lowercase().as_str() {
148        "critical" => Severity::Critical,
149        "high" => Severity::High,
150        "medium" => Severity::Medium,
151        "low" => Severity::Low,
152        _ => Severity::Informational,
153    };
154    Finding::builder()
155        .title(&f.title)
156        .description(&f.description)
157        .severity(severity)
158        .file(f.file.clone().unwrap_or_default())
159        .location(f.line.unwrap_or(0), f.column.unwrap_or(0))
160        .code(f.code_snippet.as_deref().unwrap_or(""))
161        .recommendation(f.recommendation.as_deref().unwrap_or(""))
162        .category(f.category.as_deref().unwrap_or("Plugin"))
163        .blocks_deployment(f.blocks_deployment)
164        .build()
165}
166
167impl From<PluginIpcFinding> for Finding {
168    fn from(f: PluginIpcFinding) -> Self {
169        parse_plugin_finding(&f)
170    }
171}
172
173// ── Metadata ────────────────────────────────────────────────────
174
175/// Metadata about a registered plugin.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct PluginInfo {
178    pub name: String,
179    pub version: String,
180    pub description: String,
181    pub enabled: bool,
182    pub plugin_type: PluginType,
183    pub path: Option<PathBuf>,
184}
185
186/// Whether a plugin is built-in or external.
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub enum PluginType {
189    Builtin,
190    External,
191}
192
193// ── Execution result ────────────────────────────────────────────
194
195/// The result of executing a single plugin.
196#[derive(Debug, Clone)]
197pub struct PluginExecutionResult {
198    pub plugin_name: String,
199    pub findings: Vec<Finding>,
200    pub duration: std::time::Duration,
201    pub success: bool,
202    pub error: Option<String>,
203}
204
205// ── Plugin Registry ─────────────────────────────────────────────
206
207/// The plugin registry manages plugin discovery, registration, and execution.
208pub struct PluginRegistry {
209    config: ProjectConfig,
210    /// External plugins discovered via plugin.toml files.
211    external_plugins: Vec<PluginInfo>,
212    /// Built-in plugins registered programmatically.
213    builtin_plugins: Vec<PluginInfo>,
214    /// The actual built-in plugin instances (not serialized).
215    builtin_instances: HashMap<String, Box<dyn Plugin>>,
216}
217
218impl Clone for PluginRegistry {
219    fn clone(&self) -> Self {
220        // Note: builtin_instances are intentionally not cloned because
221        // Box<dyn Plugin> does not implement Clone. The clone preserves
222        // config and plugin metadata but loses the instance map (it gets
223        // re-populated by re-registering plugins on the cloned registry).
224        Self {
225            config: self.config.clone(),
226            external_plugins: self.external_plugins.clone(),
227            builtin_plugins: self.builtin_plugins.clone(),
228            builtin_instances: HashMap::new(),
229        }
230    }
231}
232
233impl std::fmt::Debug for PluginRegistry {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct("PluginRegistry")
236            .field("config", &self.config)
237            .field("external_plugins", &self.external_plugins)
238            .field("builtin_plugins", &self.builtin_plugins)
239            .finish()
240    }
241}
242
243impl PluginRegistry {
244    /// Create a new registry, scanning configured directories for plugins.
245    pub fn new(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
246        let mut registry = Self {
247            config: config.clone(),
248            external_plugins: Vec::new(),
249            builtin_plugins: Vec::new(),
250            builtin_instances: HashMap::new(),
251        };
252        registry.scan_plugin_dirs()?;
253        Ok(registry)
254    }
255
256    /// Register a built-in plugin. Returns an error if a plugin with the same name exists.
257    pub fn register_builtin(&mut self, plugin: Box<dyn Plugin>) {
258        let name = plugin.name().to_string();
259        let version = plugin.version().to_string();
260        let description = plugin.description().to_string();
261        let enabled = !self.config.plugins.disabled.contains(&name);
262
263        // Remove previous registration if any
264        self.builtin_plugins.retain(|p| p.name != name);
265
266        self.builtin_plugins.push(PluginInfo {
267            name: name.clone(),
268            version,
269            description,
270            enabled,
271            plugin_type: PluginType::Builtin,
272            path: None,
273        });
274        self.builtin_instances.insert(name, plugin);
275    }
276
277    /// Scan configured directories for external plugins.
278    fn scan_plugin_dirs(&mut self) -> Result<(), ForgeGuardError> {
279        let dirs = &self.config.plugin_dirs;
280        // If no dirs configured, use the default from PluginConfig
281        let default_dir = PathBuf::from(".forge-guard/plugins");
282        let search_dirs: Vec<&PathBuf> = if dirs.is_empty() {
283            // Use the default .forge-guard/plugins directory
284            if default_dir.exists() {
285                vec![&default_dir]
286            } else {
287                Vec::new()
288            }
289        } else {
290            dirs.iter().collect()
291        };
292
293        for dir in search_dirs {
294            if dir.exists() && dir.is_dir() {
295                if let Ok(entries) = std::fs::read_dir(dir) {
296                    for entry in entries.flatten() {
297                        let path = entry.path();
298                        if path.is_dir() {
299                            let meta_file = path.join("plugin.toml");
300                            if meta_file.exists() {
301                                if let Ok(info) = self.load_plugin_meta(&meta_file) {
302                                    // Don't add duplicates
303                                    if !self.external_plugins.iter().any(|p| p.name == info.name) {
304                                        self.external_plugins.push(info);
305                                    }
306                                }
307                            }
308                        }
309                    }
310                }
311            }
312        }
313        Ok(())
314    }
315
316    /// Load plugin metadata from a TOML file.
317    fn load_plugin_meta(&self, path: &Path) -> Result<PluginInfo, ForgeGuardError> {
318        let content = std::fs::read_to_string(path)?;
319        #[derive(Deserialize)]
320        struct PluginMeta {
321            name: String,
322            version: String,
323            description: String,
324        }
325        let meta: PluginMeta = toml::from_str(&content)?;
326
327        let enabled = !self.config.plugins.disabled.contains(&meta.name);
328        Ok(PluginInfo {
329            name: meta.name,
330            version: meta.version,
331            description: meta.description,
332            enabled,
333            plugin_type: PluginType::External,
334            path: path.parent().map(|p| p.to_path_buf()),
335        })
336    }
337
338    /// List all registered plugins (both built-in and external).
339    pub fn list_plugins(&self) -> Vec<PluginInfo> {
340        let mut all = self.builtin_plugins.clone();
341        all.extend(self.external_plugins.clone());
342        // Sort: built-in first, then external
343        all.sort_by_key(|p| match p.plugin_type {
344            PluginType::Builtin => 0,
345            PluginType::External => 1,
346        });
347        all
348    }
349
350    /// Get info for a specific plugin by name.
351    pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
352        self.builtin_plugins
353            .iter()
354            .chain(self.external_plugins.iter())
355            .find(|p| p.name == name)
356            .cloned()
357    }
358
359    /// Get the number of registered plugins.
360    pub fn plugin_count(&self) -> usize {
361        self.builtin_plugins.len() + self.external_plugins.len()
362    }
363
364    /// Get the number of enabled plugins.
365    pub fn enabled_count(&self) -> usize {
366        self.list_plugins().iter().filter(|p| p.enabled).count()
367    }
368
369    // ── Plugin Execution ───────────────────────────────────────
370
371    /// Execute all enabled plugins and return their findings.
372    ///
373    /// Built-in plugins are executed in-process. External plugins are executed
374    /// as subprocesses via the JSON IPC protocol.
375    pub fn execute_all(&self, ctx: &PluginContext) -> Vec<PluginExecutionResult> {
376        let mut results = Vec::new();
377
378        // Execute built-in plugins
379        for info in &self.builtin_plugins {
380            if !info.enabled {
381                continue;
382            }
383            if let Some(instance) = self.builtin_instances.get(&info.name) {
384                let result = self.execute_builtin(instance.as_ref(), info, ctx);
385                results.push(result);
386            }
387        }
388
389        // Execute external plugins
390        for info in &self.external_plugins {
391            if !info.enabled {
392                continue;
393            }
394            let result = self.execute_external(info, ctx);
395            results.push(result);
396        }
397
398        results
399    }
400
401    /// Execute a single built-in plugin.
402    fn execute_builtin(
403        &self,
404        plugin: &dyn Plugin,
405        info: &PluginInfo,
406        ctx: &PluginContext,
407    ) -> PluginExecutionResult {
408        let start = Instant::now();
409        match plugin.execute(ctx) {
410            Ok(findings) => PluginExecutionResult {
411                plugin_name: info.name.clone(),
412                findings,
413                duration: start.elapsed(),
414                success: true,
415                error: None,
416            },
417            Err(e) => PluginExecutionResult {
418                plugin_name: info.name.clone(),
419                findings: Vec::new(),
420                duration: start.elapsed(),
421                success: false,
422                error: Some(e.to_string()),
423            },
424        }
425    }
426
427    /// Execute an external plugin as a subprocess.
428    fn execute_external(&self, info: &PluginInfo, ctx: &PluginContext) -> PluginExecutionResult {
429        let start = Instant::now();
430        let plugin_dir = match &info.path {
431            Some(p) => p.clone(),
432            None => {
433                return PluginExecutionResult {
434                    plugin_name: info.name.clone(),
435                    findings: Vec::new(),
436                    duration: start.elapsed(),
437                    success: false,
438                    error: Some("Plugin path unknown".to_string()),
439                };
440            }
441        };
442
443        // Determine the binary/script to execute
444        let binary = Self::find_plugin_binary(&plugin_dir);
445        if binary.is_none() {
446            return PluginExecutionResult {
447                plugin_name: info.name.clone(),
448                findings: Vec::new(),
449                duration: start.elapsed(),
450                success: false,
451                error: Some(format!(
452                    "No executable found in plugin directory: {}",
453                    plugin_dir.display()
454                )),
455            };
456        }
457        let binary = binary.unwrap();
458
459        // Build the IPC input
460        let input = PluginIpcInput {
461            protocol_version: "1.0".to_string(),
462            plugin_name: info.name.clone(),
463            context: ctx.clone(),
464        };
465
466        // Serialize to JSON
467        let input_json = match serde_json::to_string(&input) {
468            Ok(j) => j,
469            Err(e) => {
470                return PluginExecutionResult {
471                    plugin_name: info.name.clone(),
472                    findings: Vec::new(),
473                    duration: start.elapsed(),
474                    success: false,
475                    error: Some(format!("Failed to serialize IPC input: {}", e)),
476                };
477            }
478        };
479
480        // Spawn the subprocess
481        let output = match std::process::Command::new(&binary)
482            .args(["--forge-guard-ipc"])
483            .stdin(std::process::Stdio::piped())
484            .stdout(std::process::Stdio::piped())
485            .stderr(std::process::Stdio::piped())
486            .spawn()
487        {
488            Ok(mut child) => {
489                // Write input to stdin
490                use std::io::Write;
491                if let Some(mut stdin) = child.stdin.take() {
492                    let _ = stdin.write_all(input_json.as_bytes());
493                    // Close stdin to signal end of input
494                    drop(stdin);
495                }
496
497                // Wait for the process to finish with a timeout
498                match child.wait_with_output() {
499                    Ok(output) => output,
500                    Err(e) => {
501                        return PluginExecutionResult {
502                            plugin_name: info.name.clone(),
503                            findings: Vec::new(),
504                            duration: start.elapsed(),
505                            success: false,
506                            error: Some(format!("Failed to wait for plugin process: {}", e)),
507                        };
508                    }
509                }
510            }
511            Err(e) => {
512                return PluginExecutionResult {
513                    plugin_name: info.name.clone(),
514                    findings: Vec::new(),
515                    duration: start.elapsed(),
516                    success: false,
517                    error: Some(format!("Failed to spawn plugin process: {}", e)),
518                };
519            }
520        };
521
522        // Log stderr output (plugin diagnostics)
523        if !output.stderr.is_empty() {
524            let stderr = String::from_utf8_lossy(&output.stderr);
525            for line in stderr.lines() {
526                if !line.is_empty() {
527                    eprintln!("  [plugin:{}] {}", info.name, line);
528                }
529            }
530        }
531
532        // Parse stdout as JSON findings
533        if !output.status.success() {
534            let stderr = String::from_utf8_lossy(&output.stderr);
535            return PluginExecutionResult {
536                plugin_name: info.name.clone(),
537                findings: Vec::new(),
538                duration: start.elapsed(),
539                success: false,
540                error: Some(format!(
541                    "Plugin exited with code {}: {}",
542                    output.status.code().unwrap_or(-1),
543                    stderr.lines().next().unwrap_or("unknown error")
544                )),
545            };
546        }
547
548        let stdout = String::from_utf8_lossy(&output.stdout);
549        match serde_json::from_str::<PluginIpcOutput>(&stdout) {
550            Ok(ipc_output) => {
551                let findings: Vec<Finding> =
552                    ipc_output.findings.into_iter().map(|f| f.into()).collect();
553
554                if ipc_output.success {
555                    PluginExecutionResult {
556                        plugin_name: info.name.clone(),
557                        findings,
558                        duration: start.elapsed(),
559                        success: true,
560                        error: None,
561                    }
562                } else {
563                    PluginExecutionResult {
564                        plugin_name: info.name.clone(),
565                        findings,
566                        duration: start.elapsed(),
567                        success: false,
568                        error: ipc_output.error,
569                    }
570                }
571            }
572            Err(e) => PluginExecutionResult {
573                plugin_name: info.name.clone(),
574                findings: Vec::new(),
575                duration: start.elapsed(),
576                success: false,
577                error: Some(format!(
578                    "Failed to parse plugin IPC output: {}. Raw stdout: {}",
579                    e,
580                    stdout.chars().take(200).collect::<String>()
581                )),
582            },
583        }
584    }
585
586    /// Find the executable binary in a plugin directory.
587    /// Looks for: the directory name (without prefix), main binary, or a shell script.
588    fn find_plugin_binary(dir: &Path) -> Option<PathBuf> {
589        // Try the directory name as the binary (e.g., plugin "my-check" → "my-check")
590        if let Some(dir_name) = dir.file_name() {
591            let candidates = [
592                dir.join(dir_name),
593                dir.join("target").join("release").join(dir_name),
594                dir.join("target").join("debug").join(dir_name),
595            ];
596            for candidate in &candidates {
597                if candidate.exists() && is_executable(candidate) {
598                    return Some(candidate.clone());
599                }
600            }
601        }
602
603        // Try common script names
604        let script_candidates = [
605            dir.join("run.sh"),
606            dir.join("main.py"),
607            dir.join("index.js"),
608            dir.join("plugin"),
609        ];
610        for candidate in &script_candidates {
611            if candidate.exists() {
612                return Some(candidate.clone());
613            }
614        }
615
616        None
617    }
618
619    /// Enable a plugin by name.
620    pub fn enable_plugin(&mut self, name: &str) -> bool {
621        let mut found = false;
622        for plugin in &mut self.builtin_plugins {
623            if plugin.name == name {
624                plugin.enabled = true;
625                found = true;
626            }
627        }
628        for plugin in &mut self.external_plugins {
629            if plugin.name == name {
630                plugin.enabled = true;
631                found = true;
632            }
633        }
634        found
635    }
636
637    /// Disable a plugin by name.
638    pub fn disable_plugin(&mut self, name: &str) -> bool {
639        let mut found = false;
640        for plugin in &mut self.builtin_plugins {
641            if plugin.name == name {
642                plugin.enabled = false;
643                found = true;
644            }
645        }
646        for plugin in &mut self.external_plugins {
647            if plugin.name == name {
648                plugin.enabled = false;
649                found = true;
650            }
651        }
652        found
653    }
654}
655
656#[cfg(unix)]
657fn is_executable(path: &Path) -> bool {
658    use std::os::unix::fs::PermissionsExt;
659    path.is_file()
660        && path
661            .metadata()
662            .map(|m| m.permissions().mode() & 0o111 != 0)
663            .unwrap_or(false)
664}
665
666#[cfg(not(unix))]
667fn is_executable(path: &Path) -> bool {
668    path.is_file()
669}
670
671// ── Built-in Example Plugin ────────────────────────────────────
672
673/// A built-in example plugin for demonstration and self-testing.
674pub struct ExamplePlugin;
675
676impl Plugin for ExamplePlugin {
677    fn name(&self) -> &'static str {
678        "forge-guard-example"
679    }
680
681    fn version(&self) -> &'static str {
682        "0.1.0"
683    }
684
685    fn description(&self) -> &'static str {
686        "Example plugin demonstrating the plugin API"
687    }
688
689    fn execute(&self, ctx: &PluginContext) -> PluginResult {
690        eprintln!(
691            "  [plugin:{}] Analyzing {} source files",
692            self.name(),
693            ctx.source_files.len()
694        );
695        Ok(Vec::new())
696    }
697}
698
699/// A built-in "no-op" plugin that warns about missing RPC.
700pub struct OfflineGuardPlugin;
701
702impl Plugin for OfflineGuardPlugin {
703    fn name(&self) -> &'static str {
704        "forge-guard-offline-guard"
705    }
706
707    fn version(&self) -> &'static str {
708        "0.1.0"
709    }
710
711    fn description(&self) -> &'static str {
712        "Warns when plugins requiring RPC are enabled but --offline is set"
713    }
714
715    fn execute(&self, ctx: &PluginContext) -> PluginResult {
716        let offline = ctx
717            .metadata
718            .get("offline")
719            .map(|s| s == "true")
720            .unwrap_or(false);
721        if offline {
722            // In offline mode, this plugin just verifies no findings exist
723            return Ok(Vec::new());
724        }
725        Ok(Vec::new())
726    }
727}
728
729// ── Convenience initializer for common built-in plugins ────────
730
731/// Register the default set of built-in plugins into a registry.
732pub fn register_default_plugins(registry: &mut PluginRegistry) {
733    registry.register_builtin(Box::new(ExamplePlugin));
734    registry.register_builtin(Box::new(OfflineGuardPlugin));
735}