Skip to main content

forge_guard/importer/
mod.rs

1//! External analyzer import — `forge-guard import`.
2//!
3//! Imports findings from third-party security tools (Slither, Mythril,
4//! Semgrep) into Forge Guard's native [`Finding`] format, maps each tool's
5//! severity scale onto Forge Guard's 5-level scale, deduplicates against
6//! existing Forge Guard findings, and produces a unified report.
7
8use crate::core::{Finding, Severity};
9use anyhow::{bail, Context, Result};
10use serde::Serialize;
11use serde_json::Value;
12
13/// Supported external analyzer tools.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15pub enum ExternalTool {
16    /// Slither (trailofbits) — `slither . --json out.json`
17    Slither,
18    /// Mythril (Consensys) — `myth analyze ... -o mythril_out.json`
19    Mythril,
20    /// Semgrep — `semgrep scan --json`
21    Semgrep,
22}
23
24impl ExternalTool {
25    /// Parse a tool name from CLI input.
26    pub fn parse(s: &str) -> Result<Self> {
27        match s.to_lowercase().as_str() {
28            "slither" => Ok(Self::Slither),
29            "mythril" | "myth" => Ok(Self::Mythril),
30            "semgrep" => Ok(Self::Semgrep),
31            other => bail!(
32                "Unsupported analyzer: '{}'. Supported tools: slither, mythril, semgrep.",
33                other
34            ),
35        }
36    }
37
38    /// Canonical CLI name.
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Self::Slither => "slither",
42            Self::Mythril => "mythril",
43            Self::Semgrep => "semgrep",
44        }
45    }
46
47    /// Finding ID prefix used for imported findings (e.g. `SL-1`).
48    fn id_prefix(&self) -> &'static str {
49        match self {
50            Self::Slither => "SL",
51            Self::Mythril => "MY",
52            Self::Semgrep => "SG",
53        }
54    }
55}
56
57/// The unified result of an import operation.
58#[derive(Debug, Clone, Serialize)]
59pub struct ImportResult {
60    /// The analyzer the findings were imported from.
61    pub tool: ExternalTool,
62    /// Imported (and forge-guard, if provided) findings in unified format.
63    pub findings: Vec<Finding>,
64    /// Number of imported findings removed as duplicates.
65    pub duplicates_removed: usize,
66    /// Analyzer source files referenced by the findings.
67    pub source_files: Vec<String>,
68    /// Whether forge-guard's own findings were merged into the report.
69    pub merged_with_forge_guard: bool,
70}
71
72impl ImportResult {
73    /// Total number of findings after import.
74    pub fn total(&self) -> usize {
75        self.findings.len()
76    }
77
78    /// Count of findings at or above the given severity.
79    pub fn count_at_or_above(&self, min: Severity) -> usize {
80        self.findings.iter().filter(|f| f.severity >= min).count()
81    }
82}
83
84/// Import findings from raw JSON produced by the given tool.
85pub fn import_from_json(tool: ExternalTool, content: &str) -> Result<Vec<Finding>> {
86    let root: Value = serde_json::from_str(content)
87        .with_context(|| format!("{} results file is not valid JSON", tool.as_str()))?;
88
89    let raw = match tool {
90        ExternalTool::Slither => parse_slither(&root)?,
91        ExternalTool::Mythril => parse_mythril(&root)?,
92        ExternalTool::Semgrep => parse_semgrep(&root)?,
93    };
94
95    let mut findings = Vec::new();
96    for (idx, item) in raw.into_iter().enumerate() {
97        findings.push(to_finding(tool, idx + 1, item));
98    }
99    Ok(findings)
100}
101
102// ─────────────────────────────────────────────────────────────────
103// Tool-specific parsers (lenient — tolerate schema variations)
104// ─────────────────────────────────────────────────────────────────
105
106struct RawFinding {
107    title: String,
108    description: String,
109    severity: Severity,
110    file: Option<String>,
111    line: Option<usize>,
112    code_snippet: Option<String>,
113    recommendation: String,
114    category: String,
115    references: Vec<String>,
116}
117
118fn parse_slither(root: &Value) -> Result<Vec<RawFinding>> {
119    let detectors = root
120        .get("results")
121        .and_then(|r| r.get("detectors"))
122        .and_then(Value::as_array)
123        .cloned()
124        .unwrap_or_default();
125
126    let mut out = Vec::new();
127    for det in detectors {
128        let title = det
129            .get("check")
130            .and_then(Value::as_str)
131            .unwrap_or("Slither finding")
132            .to_string();
133        let impact = det
134            .get("impact")
135            .and_then(Value::as_str)
136            .unwrap_or("Informational");
137        let description = det
138            .get("description")
139            .and_then(Value::as_str)
140            .unwrap_or_default()
141            .to_string();
142
143        let (file, line, snippet) = extract_slither_element(&det);
144        let recommendation = det
145            .get("markdown")
146            .and_then(Value::as_str)
147            .unwrap_or_default()
148            .to_string();
149
150        out.push(RawFinding {
151            title: format!("{} ({})", title, impact),
152            description: description.trim().to_string(),
153            severity: map_slither_severity(impact),
154            file,
155            line,
156            code_snippet: snippet,
157            recommendation,
158            category: format!("slither:{}", title),
159            references: vec![format!("slither-detector:{}", title)],
160        });
161    }
162    Ok(out)
163}
164
165fn extract_slither_element(det: &Value) -> (Option<String>, Option<usize>, Option<String>) {
166    let element = det
167        .get("elements")
168        .and_then(Value::as_array)
169        .and_then(|els| els.first());
170    let Some(element) = element else {
171        return (None, None, None);
172    };
173    let mapping = element.get("source_mapping");
174    let file = mapping
175        .and_then(|m| m.get("filename_relative"))
176        .or_else(|| mapping.and_then(|m| m.get("filename_absolute")))
177        .and_then(Value::as_str)
178        .map(String::from);
179    let line = mapping
180        .and_then(|m| m.get("line"))
181        .and_then(Value::as_u64)
182        .map(|l| l as usize);
183    let snippet = element
184        .get("source_mapping")
185        .and_then(|m| m.get("content"))
186        .and_then(Value::as_str)
187        .map(String::from);
188    (file, line, snippet)
189}
190
191fn parse_mythril(root: &Value) -> Result<Vec<RawFinding>> {
192    let issues = root
193        .get("issues")
194        .and_then(Value::as_array)
195        .cloned()
196        .unwrap_or_default();
197
198    let mut out = Vec::new();
199    for issue in issues {
200        let title = issue
201            .get("title")
202            .and_then(Value::as_str)
203            .unwrap_or("Mythril finding")
204            .to_string();
205        // Mythril reports severity under either `severity` or `type`.
206        let level = issue
207            .get("severity")
208            .or_else(|| issue.get("type"))
209            .and_then(Value::as_str)
210            .unwrap_or("Informational");
211        let description = issue
212            .get("description")
213            .and_then(Value::as_str)
214            .unwrap_or_default()
215            .to_string();
216
217        let file = issue
218            .get("source")
219            .and_then(|s| s.get("filename"))
220            .and_then(Value::as_str)
221            .map(String::from);
222        let line = issue
223            .get("source")
224            .and_then(|s| s.get("line"))
225            .and_then(Value::as_u64)
226            .map(|l| l as usize);
227        let snippet = issue
228            .get("source")
229            .and_then(|s| s.get("source"))
230            .and_then(Value::as_str)
231            .map(String::from);
232
233        let swc_id = issue.get("swc-id").and_then(Value::as_str).unwrap_or("");
234        let mut references = Vec::new();
235        if !swc_id.is_empty() {
236            references.push(format!("SWC-{}", swc_id));
237        }
238
239        out.push(RawFinding {
240            title,
241            description: description.trim().to_string(),
242            severity: map_mythril_severity(level),
243            file,
244            line,
245            code_snippet: snippet,
246            recommendation: String::new(),
247            category: format!(
248                "mythril:{}",
249                issue
250                    .get("function")
251                    .and_then(Value::as_str)
252                    .unwrap_or("unknown")
253            ),
254            references,
255        });
256    }
257    Ok(out)
258}
259
260fn parse_semgrep(root: &Value) -> Result<Vec<RawFinding>> {
261    let results = root
262        .get("results")
263        .and_then(Value::as_array)
264        .cloned()
265        .unwrap_or_default();
266
267    let mut out = Vec::new();
268    for r in results {
269        let check_id = r
270            .get("check_id")
271            .and_then(Value::as_str)
272            .unwrap_or("semgrep-rule");
273        let extra = r.get("extra");
274        let title = extra
275            .and_then(|e| e.get("message"))
276            .and_then(Value::as_str)
277            .unwrap_or(check_id)
278            .to_string();
279        let level = extra
280            .and_then(|e| e.get("severity"))
281            .and_then(Value::as_str)
282            .unwrap_or("INFO");
283        let file = r.get("path").and_then(Value::as_str).map(String::from);
284        let line = r
285            .get("start")
286            .and_then(|s| s.get("line"))
287            .and_then(Value::as_u64)
288            .map(|l| l as usize);
289        let snippet = extra
290            .and_then(|e| e.get("lines"))
291            .and_then(Value::as_str)
292            .map(String::from);
293
294        let mut references = Vec::new();
295        if let Some(metadata) = extra.and_then(|e| e.get("metadata")) {
296            if let Some(cwes) = metadata.get("cwe").and_then(Value::as_array) {
297                for cwe in cwes {
298                    if let Some(s) = cwe.as_str() {
299                        references.push(s.to_string());
300                    }
301                }
302            }
303            if let Some(cwe) = metadata.get("cwe").and_then(Value::as_str) {
304                references.push(cwe.to_string());
305            }
306        }
307
308        out.push(RawFinding {
309            title,
310            description: String::new(),
311            severity: map_semgrep_severity(level),
312            file,
313            line,
314            code_snippet: snippet,
315            recommendation: String::new(),
316            category: format!("semgrep:{}", check_id),
317            references,
318        });
319    }
320    Ok(out)
321}
322
323// ─────────────────────────────────────────────────────────────────
324// Severity mapping
325// ─────────────────────────────────────────────────────────────────
326
327/// Map a Slither `impact` level to a Forge Guard severity.
328pub fn map_slither_severity(level: &str) -> Severity {
329    match level.to_lowercase().as_str() {
330        "high" => Severity::High,
331        "medium" => Severity::Medium,
332        "low" => Severity::Low,
333        // Optimization / Informational / Gas
334        _ => Severity::Informational,
335    }
336}
337
338/// Map a Mythril severity to a Forge Guard severity.
339pub fn map_mythril_severity(level: &str) -> Severity {
340    match level.to_lowercase().as_str() {
341        "critical" | "high" => Severity::High,
342        "medium" => Severity::Medium,
343        "low" => Severity::Low,
344        _ => Severity::Informational,
345    }
346}
347
348/// Map a Semgrep severity (ERROR / WARNING / INFO) to Forge Guard severity.
349pub fn map_semgrep_severity(level: &str) -> Severity {
350    match level.to_uppercase().as_str() {
351        "ERROR" => Severity::High,
352        "WARNING" => Severity::Medium,
353        "INFO" => Severity::Low,
354        _ => Severity::Informational,
355    }
356}
357
358fn to_finding(tool: ExternalTool, idx: usize, raw: RawFinding) -> Finding {
359    let mut builder = Finding::builder()
360        .id(&format!("{}-{}", tool.id_prefix(), idx))
361        .title(&raw.title)
362        .description(&raw.description)
363        .severity(raw.severity)
364        .category(&raw.category)
365        .recommendation(&raw.recommendation)
366        .file(raw.file.unwrap_or_else(|| "unknown".into()))
367        .location(raw.line.unwrap_or(0), 0)
368        .reference(format!("source:{}", tool.as_str()));
369    if let Some(snippet) = raw.code_snippet {
370        builder = builder.code(&snippet);
371    }
372    for reference in raw.references {
373        builder = builder.reference(reference);
374    }
375    builder.build()
376}
377
378// ─────────────────────────────────────────────────────────────────
379// Deduplication
380// ─────────────────────────────────────────────────────────────────
381
382/// A stable key identifying a finding for duplicate detection.
383fn dedup_key(f: &Finding) -> (String, String, String) {
384    let file = f.file.clone().unwrap_or_default();
385    let line = f.line.map(|l| l.to_string()).unwrap_or_default();
386    let title = f.title.to_lowercase();
387    (file, line, title)
388}
389
390/// Deduplicate imported findings against existing forge-guard findings.
391///
392/// Returns the deduplicated imported findings and the number of items
393/// removed (both intra-import duplicates and matches against `existing`).
394pub fn deduplicate(existing: &[Finding], imported: Vec<Finding>) -> (Vec<Finding>, usize) {
395    let mut seen: std::collections::HashSet<(String, String, String)> =
396        existing.iter().map(dedup_key).collect();
397    let mut kept = Vec::new();
398    let mut removed = 0usize;
399    for f in imported {
400        if seen.insert(dedup_key(&f)) {
401            kept.push(f);
402        } else {
403            removed += 1;
404        }
405    }
406    (kept, removed)
407}
408
409/// Load a forge-guard audit result JSON file and return its findings.
410pub fn load_forge_guard_findings(path: &std::path::Path) -> Result<Vec<Finding>> {
411    let content = std::fs::read_to_string(path)
412        .with_context(|| format!("Could not read {}", path.display()))?;
413    let result: crate::core::AuditResult = serde_json::from_str(&content)
414        .with_context(|| format!("{} is not a valid forge-guard audit result", path.display()))?;
415    Ok(result.findings)
416}
417
418/// Build a unified report by merging forge-guard findings with imported ones.
419///
420/// Forge-guard findings are always kept; imported findings that duplicate
421/// them (same file + line + title) are dropped.
422pub fn build_unified(
423    tool: ExternalTool,
424    forge_guard: Vec<Finding>,
425    imported: Vec<Finding>,
426) -> ImportResult {
427    let (deduped, removed) = deduplicate(&forge_guard, imported);
428    let mut combined = forge_guard;
429    combined.extend(deduped);
430
431    let mut files: Vec<String> = combined.iter().filter_map(|f| f.file.clone()).collect();
432    files.sort();
433    files.dedup();
434
435    ImportResult {
436        tool,
437        findings: combined,
438        duplicates_removed: removed,
439        source_files: files,
440        merged_with_forge_guard: true,
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    const SLITHER_JSON: &str = r#"
449    {
450      "success": true,
451      "results": {
452        "detectors": [
453          {
454            "check": "reentrancy-eth",
455            "impact": "High",
456            "confidence": "Medium",
457            "description": "Reentrancy in withdraw",
458            "elements": [
459              {
460                "type": "function",
461                "name": "withdraw",
462                "source_mapping": {
463                  "filename_relative": "contracts/Vault.sol",
464                  "line": 42,
465                  "end_line": 47,
466                  "column": 8,
467                  "content": "(bool ok, ) = msg.sender.call{value: amount}(\"\");"
468                }
469              }
470            ]
471          },
472          {
473            "check": "uninitialized-state",
474            "impact": "Low",
475            "confidence": "High",
476            "description": "State variable not initialized",
477            "elements": [
478              {
479                "type": "state_variable",
480                "name": "owner",
481                "source_mapping": {
482                  "filename_relative": "contracts/Vault.sol",
483                  "line": 10,
484                  "content": "address public owner;"
485                }
486              }
487            ]
488          }
489        ]
490      }
491    }
492    "#;
493
494    const MYTHRIL_JSON: &str = r#"
495    {
496      "success": true,
497      "issues": [
498        {
499          "title": "External call to user-supplied address",
500          "description": "The contract executes an external call",
501          "severity": "High",
502          "swc-id": "107",
503          "function": "withdraw",
504          "address": 1234,
505          "source": {
506            "filename": "contracts/Vault.sol",
507            "line": 42,
508            "source": "msg.sender.call{value: amount}(\"\");"
509          }
510        },
511        {
512          "title": "State change after external call",
513          "description": "State is written after an external call",
514          "type": "Medium",
515          "swc-id": "107",
516          "function": "withdraw",
517          "source": {
518            "filename": "contracts/Vault.sol",
519            "line": 44,
520            "source": "balances[msg.sender] -= amount;"
521          }
522        }
523      ]
524    }
525    "#;
526
527    const SEMGREP_JSON: &str = r#"
528    {
529      "results": [
530        {
531          "check_id": "solidity.reentrancy",
532          "path": "contracts/Vault.sol",
533          "start": { "line": 42, "col": 1 },
534          "end": { "line": 42, "col": 30 },
535          "extra": {
536            "message": "External call before state update",
537            "severity": "ERROR",
538            "metadata": { "cwe": ["CWE-1077"] },
539            "lines": "msg.sender.call{value: amount}(\"\");"
540          }
541        },
542        {
543          "check_id": "solidity.avoid-tx-origin",
544          "path": "contracts/Vault.sol",
545          "start": { "line": 60, "col": 1 },
546          "extra": {
547            "message": "Use of tx.origin",
548            "severity": "WARNING",
549            "metadata": { "cwe": "CWE-477" }
550          }
551        }
552      ],
553      "errors": []
554    }
555    "#;
556
557    #[test]
558    fn test_tool_from_str() {
559        assert_eq!(
560            ExternalTool::parse("slither").unwrap(),
561            ExternalTool::Slither
562        );
563        assert_eq!(
564            ExternalTool::parse("Mythril").unwrap(),
565            ExternalTool::Mythril
566        );
567        assert_eq!(
568            ExternalTool::parse("semgrep").unwrap(),
569            ExternalTool::Semgrep
570        );
571        assert!(ExternalTool::parse("solhint").is_err());
572        assert!(ExternalTool::parse("").is_err());
573    }
574
575    #[test]
576    fn test_tool_id_prefixes() {
577        assert_eq!(ExternalTool::Slither.id_prefix(), "SL");
578        assert_eq!(ExternalTool::Mythril.id_prefix(), "MY");
579        assert_eq!(ExternalTool::Semgrep.id_prefix(), "SG");
580    }
581
582    #[test]
583    fn test_parse_slither() {
584        let findings = import_from_json(ExternalTool::Slither, SLITHER_JSON).unwrap();
585        assert_eq!(findings.len(), 2);
586
587        let reentrancy = &findings[0];
588        assert!(reentrancy.id.starts_with("SL-"));
589        assert_eq!(reentrancy.severity, Severity::High);
590        assert_eq!(reentrancy.file.as_deref(), Some("contracts/Vault.sol"));
591        assert_eq!(reentrancy.line, Some(42));
592        assert!(reentrancy.code_snippet.as_deref().unwrap().contains("call"));
593        assert_eq!(reentrancy.category, "slither:reentrancy-eth");
594
595        assert_eq!(findings[1].severity, Severity::Low);
596    }
597
598    #[test]
599    fn test_parse_mythril() {
600        let findings = import_from_json(ExternalTool::Mythril, MYTHRIL_JSON).unwrap();
601        assert_eq!(findings.len(), 2);
602        assert_eq!(findings[0].severity, Severity::High);
603        assert_eq!(findings[0].file.as_deref(), Some("contracts/Vault.sol"));
604        assert_eq!(findings[0].line, Some(42));
605        assert!(findings[0].references.iter().any(|r| r == "SWC-107"));
606        // Mythril `type` field is also honored
607        assert_eq!(findings[1].severity, Severity::Medium);
608    }
609
610    #[test]
611    fn test_parse_semgrep() {
612        let findings = import_from_json(ExternalTool::Semgrep, SEMGREP_JSON).unwrap();
613        assert_eq!(findings.len(), 2);
614        assert_eq!(findings[0].severity, Severity::High);
615        assert!(findings[0]
616            .references
617            .iter()
618            .any(|r| r.contains("CWE-1077")));
619        assert_eq!(findings[0].line, Some(42));
620        assert_eq!(findings[1].severity, Severity::Medium);
621        assert!(findings[1].references.iter().any(|r| r.contains("CWE-477")));
622    }
623
624    #[test]
625    fn test_parse_invalid_json_errors() {
626        assert!(import_from_json(ExternalTool::Slither, "not json").is_err());
627    }
628
629    #[test]
630    fn test_parse_empty_results() {
631        assert!(import_from_json(ExternalTool::Slither, r#"{"results":{}}"#)
632            .unwrap()
633            .is_empty());
634        assert!(
635            import_from_json(ExternalTool::Mythril, r#"{"success":true}"#)
636                .unwrap()
637                .is_empty()
638        );
639        assert!(import_from_json(ExternalTool::Semgrep, r#"{"results":[]}"#)
640            .unwrap()
641            .is_empty());
642    }
643
644    #[test]
645    fn test_severity_mapping_tables() {
646        assert_eq!(map_slither_severity("High"), Severity::High);
647        assert_eq!(map_slither_severity("Medium"), Severity::Medium);
648        assert_eq!(map_slither_severity("Low"), Severity::Low);
649        assert_eq!(
650            map_slither_severity("Informational"),
651            Severity::Informational
652        );
653        assert_eq!(
654            map_slither_severity("Optimization"),
655            Severity::Informational
656        );
657
658        assert_eq!(map_mythril_severity("High"), Severity::High);
659        assert_eq!(map_mythril_severity("Medium"), Severity::Medium);
660        assert_eq!(map_mythril_severity("Low"), Severity::Low);
661        assert_eq!(
662            map_mythril_severity("Informational"),
663            Severity::Informational
664        );
665        assert_eq!(map_mythril_severity("unknown"), Severity::Informational);
666
667        assert_eq!(map_semgrep_severity("ERROR"), Severity::High);
668        assert_eq!(map_semgrep_severity("WARNING"), Severity::Medium);
669        assert_eq!(map_semgrep_severity("INFO"), Severity::Low);
670        assert_eq!(map_semgrep_severity("error"), Severity::High);
671        assert_eq!(map_semgrep_severity("NONE"), Severity::Informational);
672    }
673
674    fn sample_finding(id: &str, title: &str, file: &str, line: usize, sev: Severity) -> Finding {
675        Finding::builder()
676            .id(id)
677            .title(title)
678            .description("desc")
679            .severity(sev)
680            .file(file)
681            .location(line, 0)
682            .recommendation("fix it")
683            .category("Security")
684            .build()
685    }
686
687    #[test]
688    fn test_dedup_against_forge_guard() {
689        let existing = vec![sample_finding(
690            "FA-H-001-1",
691            "Reentrancy in withdraw",
692            "contracts/Vault.sol",
693            42,
694            Severity::High,
695        )];
696        let imported = vec![
697            sample_finding(
698                "SL-1",
699                "Reentrancy in withdraw",
700                "contracts/Vault.sol",
701                42,
702                Severity::High,
703            ),
704            sample_finding(
705                "SL-2",
706                "Unchecked return value",
707                "contracts/Vault.sol",
708                80,
709                Severity::Low,
710            ),
711            sample_finding(
712                "SL-3",
713                "Unchecked return value",
714                "contracts/Vault.sol",
715                80,
716                Severity::Low,
717            ),
718        ];
719        let (kept, removed) = deduplicate(&existing, imported);
720        // SL-1 matches forge-guard; SL-3 duplicates SL-2
721        assert_eq!(removed, 2);
722        assert_eq!(kept.len(), 1);
723        assert_eq!(kept[0].id, "SL-2");
724    }
725
726    #[test]
727    fn test_dedup_different_location_kept() {
728        let existing = vec![sample_finding(
729            "FA-H-001-1",
730            "Reentrancy",
731            "Vault.sol",
732            42,
733            Severity::High,
734        )];
735        let imported = vec![sample_finding(
736            "SL-1",
737            "Reentrancy",
738            "Vault.sol",
739            99,
740            Severity::High,
741        )];
742        let (kept, removed) = deduplicate(&existing, imported);
743        assert_eq!(removed, 0);
744        assert_eq!(kept.len(), 1);
745    }
746
747    #[test]
748    fn test_build_unified_merges() {
749        let fg = vec![sample_finding(
750            "FA-H-001-1",
751            "Reentrancy",
752            "Vault.sol",
753            42,
754            Severity::High,
755        )];
756        let imported = vec![
757            sample_finding("SL-1", "Reentrancy", "Vault.sol", 42, Severity::High),
758            sample_finding("SL-2", "Unchecked send", "Vault.sol", 90, Severity::Medium),
759        ];
760        let unified = build_unified(ExternalTool::Slither, fg, imported);
761        assert_eq!(unified.total(), 2);
762        assert_eq!(unified.duplicates_removed, 1);
763        assert!(unified.merged_with_forge_guard);
764        assert!(unified.source_files.contains(&"Vault.sol".to_string()));
765        assert_eq!(unified.count_at_or_above(Severity::Medium), 2);
766        assert_eq!(unified.count_at_or_above(Severity::High), 1);
767    }
768
769    #[test]
770    fn test_import_result_serde_roundtrip() {
771        let unified = build_unified(
772            ExternalTool::Semgrep,
773            Vec::new(),
774            import_from_json(ExternalTool::Semgrep, SEMGREP_JSON).unwrap(),
775        );
776        let json = serde_json::to_string(&unified).unwrap();
777        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
778        assert_eq!(parsed["tool"], "Semgrep");
779        assert_eq!(parsed["findings"].as_array().unwrap().len(), 2);
780    }
781}