Skip to main content

sentri_core/
finding.rs

1//! Unified finding type for all detectors across all chains.
2//!
3//! A `Finding` represents a security issue discovered by an invariant detector.
4//! All detectors (EVM, Solana, Move) produce findings in this format.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Severity levels for findings.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
11pub enum Severity {
12    /// Informational: Best practices or informational notices.
13    Info,
14    /// Low severity: Minor issues with low impact.
15    Low,
16    /// Medium severity: Moderate issues requiring attention.
17    Medium,
18    /// High severity: Serious issues requiring urgent action.
19    High,
20    /// Critical severity: Severe vulnerabilities requiring immediate remediation.
21    Critical,
22}
23
24impl Severity {
25    /// Get numeric value for ordering and comparison.
26    pub fn value(self) -> u32 {
27        match self {
28            Self::Info => 0,
29            Self::Low => 1,
30            Self::Medium => 2,
31            Self::High => 3,
32            Self::Critical => 4,
33        }
34    }
35
36    /// Get human-readable name.
37    pub fn name(self) -> &'static str {
38        match self {
39            Self::Info => "INFO",
40            Self::Low => "LOW",
41            Self::Medium => "MEDIUM",
42            Self::High => "HIGH",
43            Self::Critical => "CRITICAL",
44        }
45    }
46
47    /// Get ANSI color code for terminal output.
48    pub fn ansi_color(self) -> &'static str {
49        match self {
50            Self::Info => "\x1b[36m",       // Cyan
51            Self::Low => "\x1b[34m",        // Blue
52            Self::Medium => "\x1b[33m",     // Yellow
53            Self::High => "\x1b[1;33m",     // Bold yellow
54            Self::Critical => "\x1b[1;31m", // Bold red
55        }
56    }
57}
58
59impl fmt::Display for Severity {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{}", self.name())
62    }
63}
64
65/// A security finding discovered by a detector.
66///
67/// This is the unified output type for all invariant violations, accessible
68/// across EVM, Solana, Move, and other analyzers.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct Finding {
71    /// Unique identifier for the invariant that was violated.
72    /// Example: "evm_reentrancy_classic", "sol_missing_signer", "move_access_control_missing"
73    pub invariant_id: String,
74
75    /// Severity of this finding.
76    pub severity: Severity,
77
78    /// Source file path (relative to project root).
79    pub file: String,
80
81    /// Line number in source file (1-indexed).
82    pub line: usize,
83
84    /// Column number in source file (0-indexed).
85    pub col: usize,
86
87    /// Human-readable description of the finding.
88    /// Should explain what was found and why it's a problem.
89    pub message: String,
90
91    /// Code snippet showing the problematic code (for context in reports).
92    /// Include a few lines of context around the issue.
93    pub snippet: String,
94
95    /// Optional source code fragment (full function or smaller unit).
96    /// Useful for detailed analysis.
97    pub source_fragment: Option<String>,
98
99    /// Optional transaction hash or test case ID (for runtime detections).
100    pub transaction_hash: Option<String>,
101
102    /// Additional metadata (chain, detector version, etc).
103    /// Can be used by report formatters.
104    pub metadata: std::collections::BTreeMap<String, String>,
105}
106
107impl Finding {
108    /// Create a new finding.
109    pub fn new(
110        invariant_id: String,
111        severity: Severity,
112        file: String,
113        line: usize,
114        col: usize,
115        message: String,
116        snippet: String,
117    ) -> Self {
118        Self {
119            invariant_id,
120            severity,
121            file,
122            line,
123            col,
124            message,
125            snippet,
126            source_fragment: None,
127            transaction_hash: None,
128            metadata: Default::default(),
129        }
130    }
131
132    /// Add metadata to this finding.
133    pub fn with_metadata(mut self, key: String, value: String) -> Self {
134        self.metadata.insert(key, value);
135        self
136    }
137
138    /// Set the source fragment for this finding.
139    pub fn with_source_fragment(mut self, fragment: String) -> Self {
140        self.source_fragment = Some(fragment);
141        self
142    }
143
144    /// Set the transaction hash for this finding (runtime detections).
145    pub fn with_transaction_hash(mut self, tx_hash: String) -> Self {
146        self.transaction_hash = Some(tx_hash);
147        self
148    }
149
150    /// Get a unique key for deduplication (invariant_id + file + line).
151    pub fn dedup_key(&self) -> String {
152        format!("{}:{}:{}", self.invariant_id, self.file, self.line)
153    }
154}
155
156impl fmt::Display for Finding {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        write!(
159            f,
160            "[{}] {} at {}:{}:{} - {}",
161            self.severity, self.invariant_id, self.file, self.line, self.col, self.message
162        )
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_severity_ordering() {
172        assert!(Severity::Critical > Severity::High);
173        assert!(Severity::High > Severity::Medium);
174        assert!(Severity::Low < Severity::Medium);
175    }
176
177    #[test]
178    fn test_finding_dedup_key() {
179        let f = Finding::new(
180            "test_invariant".to_string(),
181            Severity::High,
182            "contract.sol".to_string(),
183            42,
184            10,
185            "Test message".to_string(),
186            "code snippet".to_string(),
187        );
188        assert_eq!(f.dedup_key(), "test_invariant:contract.sol:42");
189    }
190
191    #[test]
192    fn test_finding_with_metadata() {
193        let f = Finding::new(
194            "test".to_string(),
195            Severity::Medium,
196            "file.rs".to_string(),
197            1,
198            0,
199            "msg".to_string(),
200            "snippet".to_string(),
201        )
202        .with_metadata("chain".to_string(), "evm".to_string());
203
204        assert_eq!(f.metadata.get("chain"), Some(&"evm".to_string()));
205    }
206}