use crate::core::{Finding, Severity};
use anyhow::{bail, Context, Result};
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ExternalTool {
Slither,
Mythril,
Semgrep,
}
impl ExternalTool {
pub fn parse(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"slither" => Ok(Self::Slither),
"mythril" | "myth" => Ok(Self::Mythril),
"semgrep" => Ok(Self::Semgrep),
other => bail!(
"Unsupported analyzer: '{}'. Supported tools: slither, mythril, semgrep.",
other
),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Slither => "slither",
Self::Mythril => "mythril",
Self::Semgrep => "semgrep",
}
}
fn id_prefix(&self) -> &'static str {
match self {
Self::Slither => "SL",
Self::Mythril => "MY",
Self::Semgrep => "SG",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ImportResult {
pub tool: ExternalTool,
pub findings: Vec<Finding>,
pub duplicates_removed: usize,
pub source_files: Vec<String>,
pub merged_with_forge_guard: bool,
}
impl ImportResult {
pub fn total(&self) -> usize {
self.findings.len()
}
pub fn count_at_or_above(&self, min: Severity) -> usize {
self.findings.iter().filter(|f| f.severity >= min).count()
}
}
pub fn import_from_json(tool: ExternalTool, content: &str) -> Result<Vec<Finding>> {
let root: Value = serde_json::from_str(content)
.with_context(|| format!("{} results file is not valid JSON", tool.as_str()))?;
let raw = match tool {
ExternalTool::Slither => parse_slither(&root)?,
ExternalTool::Mythril => parse_mythril(&root)?,
ExternalTool::Semgrep => parse_semgrep(&root)?,
};
let mut findings = Vec::new();
for (idx, item) in raw.into_iter().enumerate() {
findings.push(to_finding(tool, idx + 1, item));
}
Ok(findings)
}
struct RawFinding {
title: String,
description: String,
severity: Severity,
file: Option<String>,
line: Option<usize>,
code_snippet: Option<String>,
recommendation: String,
category: String,
references: Vec<String>,
}
fn parse_slither(root: &Value) -> Result<Vec<RawFinding>> {
let detectors = root
.get("results")
.and_then(|r| r.get("detectors"))
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for det in detectors {
let title = det
.get("check")
.and_then(Value::as_str)
.unwrap_or("Slither finding")
.to_string();
let impact = det
.get("impact")
.and_then(Value::as_str)
.unwrap_or("Informational");
let description = det
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let (file, line, snippet) = extract_slither_element(&det);
let recommendation = det
.get("markdown")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
out.push(RawFinding {
title: format!("{} ({})", title, impact),
description: description.trim().to_string(),
severity: map_slither_severity(impact),
file,
line,
code_snippet: snippet,
recommendation,
category: format!("slither:{}", title),
references: vec![format!("slither-detector:{}", title)],
});
}
Ok(out)
}
fn extract_slither_element(det: &Value) -> (Option<String>, Option<usize>, Option<String>) {
let element = det
.get("elements")
.and_then(Value::as_array)
.and_then(|els| els.first());
let Some(element) = element else {
return (None, None, None);
};
let mapping = element.get("source_mapping");
let file = mapping
.and_then(|m| m.get("filename_relative"))
.or_else(|| mapping.and_then(|m| m.get("filename_absolute")))
.and_then(Value::as_str)
.map(String::from);
let line = mapping
.and_then(|m| m.get("line"))
.and_then(Value::as_u64)
.map(|l| l as usize);
let snippet = element
.get("source_mapping")
.and_then(|m| m.get("content"))
.and_then(Value::as_str)
.map(String::from);
(file, line, snippet)
}
fn parse_mythril(root: &Value) -> Result<Vec<RawFinding>> {
let issues = root
.get("issues")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for issue in issues {
let title = issue
.get("title")
.and_then(Value::as_str)
.unwrap_or("Mythril finding")
.to_string();
let level = issue
.get("severity")
.or_else(|| issue.get("type"))
.and_then(Value::as_str)
.unwrap_or("Informational");
let description = issue
.get("description")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let file = issue
.get("source")
.and_then(|s| s.get("filename"))
.and_then(Value::as_str)
.map(String::from);
let line = issue
.get("source")
.and_then(|s| s.get("line"))
.and_then(Value::as_u64)
.map(|l| l as usize);
let snippet = issue
.get("source")
.and_then(|s| s.get("source"))
.and_then(Value::as_str)
.map(String::from);
let swc_id = issue.get("swc-id").and_then(Value::as_str).unwrap_or("");
let mut references = Vec::new();
if !swc_id.is_empty() {
references.push(format!("SWC-{}", swc_id));
}
out.push(RawFinding {
title,
description: description.trim().to_string(),
severity: map_mythril_severity(level),
file,
line,
code_snippet: snippet,
recommendation: String::new(),
category: format!(
"mythril:{}",
issue
.get("function")
.and_then(Value::as_str)
.unwrap_or("unknown")
),
references,
});
}
Ok(out)
}
fn parse_semgrep(root: &Value) -> Result<Vec<RawFinding>> {
let results = root
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut out = Vec::new();
for r in results {
let check_id = r
.get("check_id")
.and_then(Value::as_str)
.unwrap_or("semgrep-rule");
let extra = r.get("extra");
let title = extra
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.unwrap_or(check_id)
.to_string();
let level = extra
.and_then(|e| e.get("severity"))
.and_then(Value::as_str)
.unwrap_or("INFO");
let file = r.get("path").and_then(Value::as_str).map(String::from);
let line = r
.get("start")
.and_then(|s| s.get("line"))
.and_then(Value::as_u64)
.map(|l| l as usize);
let snippet = extra
.and_then(|e| e.get("lines"))
.and_then(Value::as_str)
.map(String::from);
let mut references = Vec::new();
if let Some(metadata) = extra.and_then(|e| e.get("metadata")) {
if let Some(cwes) = metadata.get("cwe").and_then(Value::as_array) {
for cwe in cwes {
if let Some(s) = cwe.as_str() {
references.push(s.to_string());
}
}
}
if let Some(cwe) = metadata.get("cwe").and_then(Value::as_str) {
references.push(cwe.to_string());
}
}
out.push(RawFinding {
title,
description: String::new(),
severity: map_semgrep_severity(level),
file,
line,
code_snippet: snippet,
recommendation: String::new(),
category: format!("semgrep:{}", check_id),
references,
});
}
Ok(out)
}
pub fn map_slither_severity(level: &str) -> Severity {
match level.to_lowercase().as_str() {
"high" => Severity::High,
"medium" => Severity::Medium,
"low" => Severity::Low,
_ => Severity::Informational,
}
}
pub fn map_mythril_severity(level: &str) -> Severity {
match level.to_lowercase().as_str() {
"critical" | "high" => Severity::High,
"medium" => Severity::Medium,
"low" => Severity::Low,
_ => Severity::Informational,
}
}
pub fn map_semgrep_severity(level: &str) -> Severity {
match level.to_uppercase().as_str() {
"ERROR" => Severity::High,
"WARNING" => Severity::Medium,
"INFO" => Severity::Low,
_ => Severity::Informational,
}
}
fn to_finding(tool: ExternalTool, idx: usize, raw: RawFinding) -> Finding {
let mut builder = Finding::builder()
.id(&format!("{}-{}", tool.id_prefix(), idx))
.title(&raw.title)
.description(&raw.description)
.severity(raw.severity)
.category(&raw.category)
.recommendation(&raw.recommendation)
.file(raw.file.unwrap_or_else(|| "unknown".into()))
.location(raw.line.unwrap_or(0), 0)
.reference(format!("source:{}", tool.as_str()));
if let Some(snippet) = raw.code_snippet {
builder = builder.code(&snippet);
}
for reference in raw.references {
builder = builder.reference(reference);
}
builder.build()
}
fn dedup_key(f: &Finding) -> (String, String, String) {
let file = f.file.clone().unwrap_or_default();
let line = f.line.map(|l| l.to_string()).unwrap_or_default();
let title = f.title.to_lowercase();
(file, line, title)
}
pub fn deduplicate(existing: &[Finding], imported: Vec<Finding>) -> (Vec<Finding>, usize) {
let mut seen: std::collections::HashSet<(String, String, String)> =
existing.iter().map(dedup_key).collect();
let mut kept = Vec::new();
let mut removed = 0usize;
for f in imported {
if seen.insert(dedup_key(&f)) {
kept.push(f);
} else {
removed += 1;
}
}
(kept, removed)
}
pub fn load_forge_guard_findings(path: &std::path::Path) -> Result<Vec<Finding>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Could not read {}", path.display()))?;
let result: crate::core::AuditResult = serde_json::from_str(&content)
.with_context(|| format!("{} is not a valid forge-guard audit result", path.display()))?;
Ok(result.findings)
}
pub fn build_unified(
tool: ExternalTool,
forge_guard: Vec<Finding>,
imported: Vec<Finding>,
) -> ImportResult {
let (deduped, removed) = deduplicate(&forge_guard, imported);
let mut combined = forge_guard;
combined.extend(deduped);
let mut files: Vec<String> = combined.iter().filter_map(|f| f.file.clone()).collect();
files.sort();
files.dedup();
ImportResult {
tool,
findings: combined,
duplicates_removed: removed,
source_files: files,
merged_with_forge_guard: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SLITHER_JSON: &str = r#"
{
"success": true,
"results": {
"detectors": [
{
"check": "reentrancy-eth",
"impact": "High",
"confidence": "Medium",
"description": "Reentrancy in withdraw",
"elements": [
{
"type": "function",
"name": "withdraw",
"source_mapping": {
"filename_relative": "contracts/Vault.sol",
"line": 42,
"end_line": 47,
"column": 8,
"content": "(bool ok, ) = msg.sender.call{value: amount}(\"\");"
}
}
]
},
{
"check": "uninitialized-state",
"impact": "Low",
"confidence": "High",
"description": "State variable not initialized",
"elements": [
{
"type": "state_variable",
"name": "owner",
"source_mapping": {
"filename_relative": "contracts/Vault.sol",
"line": 10,
"content": "address public owner;"
}
}
]
}
]
}
}
"#;
const MYTHRIL_JSON: &str = r#"
{
"success": true,
"issues": [
{
"title": "External call to user-supplied address",
"description": "The contract executes an external call",
"severity": "High",
"swc-id": "107",
"function": "withdraw",
"address": 1234,
"source": {
"filename": "contracts/Vault.sol",
"line": 42,
"source": "msg.sender.call{value: amount}(\"\");"
}
},
{
"title": "State change after external call",
"description": "State is written after an external call",
"type": "Medium",
"swc-id": "107",
"function": "withdraw",
"source": {
"filename": "contracts/Vault.sol",
"line": 44,
"source": "balances[msg.sender] -= amount;"
}
}
]
}
"#;
const SEMGREP_JSON: &str = r#"
{
"results": [
{
"check_id": "solidity.reentrancy",
"path": "contracts/Vault.sol",
"start": { "line": 42, "col": 1 },
"end": { "line": 42, "col": 30 },
"extra": {
"message": "External call before state update",
"severity": "ERROR",
"metadata": { "cwe": ["CWE-1077"] },
"lines": "msg.sender.call{value: amount}(\"\");"
}
},
{
"check_id": "solidity.avoid-tx-origin",
"path": "contracts/Vault.sol",
"start": { "line": 60, "col": 1 },
"extra": {
"message": "Use of tx.origin",
"severity": "WARNING",
"metadata": { "cwe": "CWE-477" }
}
}
],
"errors": []
}
"#;
#[test]
fn test_tool_from_str() {
assert_eq!(
ExternalTool::parse("slither").unwrap(),
ExternalTool::Slither
);
assert_eq!(
ExternalTool::parse("Mythril").unwrap(),
ExternalTool::Mythril
);
assert_eq!(
ExternalTool::parse("semgrep").unwrap(),
ExternalTool::Semgrep
);
assert!(ExternalTool::parse("solhint").is_err());
assert!(ExternalTool::parse("").is_err());
}
#[test]
fn test_tool_id_prefixes() {
assert_eq!(ExternalTool::Slither.id_prefix(), "SL");
assert_eq!(ExternalTool::Mythril.id_prefix(), "MY");
assert_eq!(ExternalTool::Semgrep.id_prefix(), "SG");
}
#[test]
fn test_parse_slither() {
let findings = import_from_json(ExternalTool::Slither, SLITHER_JSON).unwrap();
assert_eq!(findings.len(), 2);
let reentrancy = &findings[0];
assert!(reentrancy.id.starts_with("SL-"));
assert_eq!(reentrancy.severity, Severity::High);
assert_eq!(reentrancy.file.as_deref(), Some("contracts/Vault.sol"));
assert_eq!(reentrancy.line, Some(42));
assert!(reentrancy.code_snippet.as_deref().unwrap().contains("call"));
assert_eq!(reentrancy.category, "slither:reentrancy-eth");
assert_eq!(findings[1].severity, Severity::Low);
}
#[test]
fn test_parse_mythril() {
let findings = import_from_json(ExternalTool::Mythril, MYTHRIL_JSON).unwrap();
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].severity, Severity::High);
assert_eq!(findings[0].file.as_deref(), Some("contracts/Vault.sol"));
assert_eq!(findings[0].line, Some(42));
assert!(findings[0].references.iter().any(|r| r == "SWC-107"));
assert_eq!(findings[1].severity, Severity::Medium);
}
#[test]
fn test_parse_semgrep() {
let findings = import_from_json(ExternalTool::Semgrep, SEMGREP_JSON).unwrap();
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].severity, Severity::High);
assert!(findings[0]
.references
.iter()
.any(|r| r.contains("CWE-1077")));
assert_eq!(findings[0].line, Some(42));
assert_eq!(findings[1].severity, Severity::Medium);
assert!(findings[1].references.iter().any(|r| r.contains("CWE-477")));
}
#[test]
fn test_parse_invalid_json_errors() {
assert!(import_from_json(ExternalTool::Slither, "not json").is_err());
}
#[test]
fn test_parse_empty_results() {
assert!(import_from_json(ExternalTool::Slither, r#"{"results":{}}"#)
.unwrap()
.is_empty());
assert!(
import_from_json(ExternalTool::Mythril, r#"{"success":true}"#)
.unwrap()
.is_empty()
);
assert!(import_from_json(ExternalTool::Semgrep, r#"{"results":[]}"#)
.unwrap()
.is_empty());
}
#[test]
fn test_severity_mapping_tables() {
assert_eq!(map_slither_severity("High"), Severity::High);
assert_eq!(map_slither_severity("Medium"), Severity::Medium);
assert_eq!(map_slither_severity("Low"), Severity::Low);
assert_eq!(
map_slither_severity("Informational"),
Severity::Informational
);
assert_eq!(
map_slither_severity("Optimization"),
Severity::Informational
);
assert_eq!(map_mythril_severity("High"), Severity::High);
assert_eq!(map_mythril_severity("Medium"), Severity::Medium);
assert_eq!(map_mythril_severity("Low"), Severity::Low);
assert_eq!(
map_mythril_severity("Informational"),
Severity::Informational
);
assert_eq!(map_mythril_severity("unknown"), Severity::Informational);
assert_eq!(map_semgrep_severity("ERROR"), Severity::High);
assert_eq!(map_semgrep_severity("WARNING"), Severity::Medium);
assert_eq!(map_semgrep_severity("INFO"), Severity::Low);
assert_eq!(map_semgrep_severity("error"), Severity::High);
assert_eq!(map_semgrep_severity("NONE"), Severity::Informational);
}
fn sample_finding(id: &str, title: &str, file: &str, line: usize, sev: Severity) -> Finding {
Finding::builder()
.id(id)
.title(title)
.description("desc")
.severity(sev)
.file(file)
.location(line, 0)
.recommendation("fix it")
.category("Security")
.build()
}
#[test]
fn test_dedup_against_forge_guard() {
let existing = vec![sample_finding(
"FA-H-001-1",
"Reentrancy in withdraw",
"contracts/Vault.sol",
42,
Severity::High,
)];
let imported = vec![
sample_finding(
"SL-1",
"Reentrancy in withdraw",
"contracts/Vault.sol",
42,
Severity::High,
),
sample_finding(
"SL-2",
"Unchecked return value",
"contracts/Vault.sol",
80,
Severity::Low,
),
sample_finding(
"SL-3",
"Unchecked return value",
"contracts/Vault.sol",
80,
Severity::Low,
),
];
let (kept, removed) = deduplicate(&existing, imported);
assert_eq!(removed, 2);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].id, "SL-2");
}
#[test]
fn test_dedup_different_location_kept() {
let existing = vec![sample_finding(
"FA-H-001-1",
"Reentrancy",
"Vault.sol",
42,
Severity::High,
)];
let imported = vec![sample_finding(
"SL-1",
"Reentrancy",
"Vault.sol",
99,
Severity::High,
)];
let (kept, removed) = deduplicate(&existing, imported);
assert_eq!(removed, 0);
assert_eq!(kept.len(), 1);
}
#[test]
fn test_build_unified_merges() {
let fg = vec![sample_finding(
"FA-H-001-1",
"Reentrancy",
"Vault.sol",
42,
Severity::High,
)];
let imported = vec![
sample_finding("SL-1", "Reentrancy", "Vault.sol", 42, Severity::High),
sample_finding("SL-2", "Unchecked send", "Vault.sol", 90, Severity::Medium),
];
let unified = build_unified(ExternalTool::Slither, fg, imported);
assert_eq!(unified.total(), 2);
assert_eq!(unified.duplicates_removed, 1);
assert!(unified.merged_with_forge_guard);
assert!(unified.source_files.contains(&"Vault.sol".to_string()));
assert_eq!(unified.count_at_or_above(Severity::Medium), 2);
assert_eq!(unified.count_at_or_above(Severity::High), 1);
}
#[test]
fn test_import_result_serde_roundtrip() {
let unified = build_unified(
ExternalTool::Semgrep,
Vec::new(),
import_from_json(ExternalTool::Semgrep, SEMGREP_JSON).unwrap(),
);
let json = serde_json::to_string(&unified).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["tool"], "Semgrep");
assert_eq!(parsed["findings"].as_array().unwrap().len(), 2);
}
}