securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
use super::{CliError, CliToolBridge};
use crate::core::{Confidence, Finding, Severity};
use std::path::Path;
use walkdir::WalkDir;

const BINARY: &str = "fickling";

const PICKLE_EXTENSIONS: &[&str] = &[".pkl", ".pt", ".bin", ".pth"];

pub struct FicklingBridge {
    bridge: CliToolBridge,
}

impl Default for FicklingBridge {
    fn default() -> Self {
        Self::new()
    }
}

impl FicklingBridge {
    pub fn new() -> Self {
        Self {
            bridge: CliToolBridge::new(BINARY),
        }
    }

    pub fn is_available(&self) -> bool {
        self.bridge.is_available()
    }

    /// Scan a directory for pickle files and analyze each with fickling.
    pub async fn scan(&self, path: &Path) -> Result<Vec<Finding>, CliError> {
        let mut all_findings = Vec::new();

        let files: Vec<_> = if path.is_file() {
            vec![path.to_path_buf()]
        } else {
            WalkDir::new(path)
                .follow_links(false)
                .into_iter()
                .filter_map(|e| e.ok())
                .filter(|e| e.path().is_file())
                .filter(|e| {
                    let name = e.path().to_string_lossy().to_lowercase();
                    PICKLE_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
                })
                .map(|e| e.path().to_path_buf())
                .collect()
        };

        for file in files {
            let file_str = file.to_string_lossy();
            match self.bridge.run(&["--json", &file_str]).await {
                Ok((_exit_code, stdout, _stderr)) => {
                    all_findings.extend(parse_fickling_output(&stdout, &file));
                }
                Err(CliError::NotInstalled(_)) => {
                    return Err(CliError::NotInstalled(format!("{} not found", BINARY)))
                }
                Err(e) => {
                    // Per-file errors are non-fatal
                    all_findings.push(Finding::new(
                        "fickling-error".to_string(),
                        format!("Fickling analysis failed for {}", file.display()),
                        Severity::Low,
                    ));
                    let _ = e; // suppress unused warning
                }
            }
        }

        Ok(all_findings)
    }
}

/// Parse fickling JSON output for a single file into Findings.
pub fn parse_fickling_output(json_text: &str, file_path: &Path) -> Vec<Finding> {
    let value: serde_json::Value = match serde_json::from_str(json_text) {
        Ok(v) => v,
        Err(_) => return Vec::new(),
    };

    let mut findings = Vec::new();

    // fickling outputs analysis with severity/issues
    if let Some(severity) = value.get("severity").and_then(|v| v.as_str()) {
        if severity != "clean" && severity != "benign" {
            let sev = match severity.to_lowercase().as_str() {
                "critical" => Severity::Critical,
                "high" | "suspicious" | "malicious" => Severity::High,
                "medium" | "likely_overhead" => Severity::Medium,
                _ => Severity::Medium,
            };

            let mut finding = Finding::new(
                "fickling-analysis".to_string(),
                format!("Fickling: {} pickle file", severity),
                sev,
            );
            finding.confidence = Confidence::Medium;
            finding.file_path = Some(file_path.to_path_buf());
            finding.cwe_ids.push(502);
            finding.tags.push("model-security".to_string());
            finding.tags.push("deserialization".to_string());

            if let Some(desc) = value.get("description").and_then(|v| v.as_str()) {
                finding.description = desc.to_string();
            }

            findings.push(finding);
        }
    }

    // Also check for "overtaken_calls" or "injected_code" arrays
    for key in &["overtaken_calls", "injected_code"] {
        if let Some(items) = value.get(*key).and_then(|v| v.as_array()) {
            for item in items {
                let desc = item
                    .as_str()
                    .or_else(|| item.get("call").and_then(|v| v.as_str()))
                    .unwrap_or("unknown");

                let mut finding = Finding::new(
                    format!("fickling-{}", key),
                    format!("Fickling: {} in pickle", key.replace('_', " ")),
                    Severity::High,
                );
                finding.confidence = Confidence::High;
                finding.file_path = Some(file_path.to_path_buf());
                finding.description = desc.to_string();
                finding.cwe_ids.push(502);
                finding.tags.push("model-security".to_string());

                findings.push(finding);
            }
        }
    }

    findings
}