1use crate::core::{Finding, Severity};
9use anyhow::{bail, Context, Result};
10use serde::Serialize;
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15pub enum ExternalTool {
16 Slither,
18 Mythril,
20 Semgrep,
22}
23
24impl ExternalTool {
25 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 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 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#[derive(Debug, Clone, Serialize)]
59pub struct ImportResult {
60 pub tool: ExternalTool,
62 pub findings: Vec<Finding>,
64 pub duplicates_removed: usize,
66 pub source_files: Vec<String>,
68 pub merged_with_forge_guard: bool,
70}
71
72impl ImportResult {
73 pub fn total(&self) -> usize {
75 self.findings.len()
76 }
77
78 pub fn count_at_or_above(&self, min: Severity) -> usize {
80 self.findings.iter().filter(|f| f.severity >= min).count()
81 }
82}
83
84pub 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
102struct 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 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
323pub 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 _ => Severity::Informational,
335 }
336}
337
338pub 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
348pub 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
378fn 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
390pub 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
409pub 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
418pub 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 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 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}