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()
}
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) => {
all_findings.push(Finding::new(
"fickling-error".to_string(),
format!("Fickling analysis failed for {}", file.display()),
Severity::Low,
));
let _ = e; }
}
}
Ok(all_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();
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);
}
}
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
}