Skip to main content

secfinding/
evidence.rs

1//! Typed evidence attached to findings.
2//!
3//! Each variant carries structured proof. Consumers use the tag to
4//! render evidence correctly (terminal, markdown, SARIF, etc.).
5
6use serde::{Deserialize, Serialize};
7
8/// Concrete evidence proving a finding is real.
9///
10/// Extensible via `#[non_exhaustive]` — new evidence types can be added
11/// for new tools (firmware, mobile, etc.) without breaking existing consumers.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14#[non_exhaustive]
15pub enum Evidence {
16    /// HTTP response data (status, headers, body excerpt).
17    HttpResponse {
18        /// HTTP status code.
19        status: u16,
20        /// Response headers as key-value pairs.
21        headers: Vec<(String, String)>,
22        /// First N bytes of the response body.
23        body_excerpt: Option<String>,
24    },
25
26    /// DNS record evidence.
27    DnsRecord {
28        /// Record type (A, AAAA, CNAME, MX, TXT, etc.).
29        record_type: String,
30        /// Record value.
31        value: String,
32    },
33
34    /// Service banner captured during port scanning.
35    Banner {
36        /// Raw banner text.
37        raw: String,
38    },
39
40    /// JavaScript source snippet with context.
41    JsSnippet {
42        /// URL of the JS file.
43        url: String,
44        /// Line number in the file.
45        line: usize,
46        /// The matched code snippet.
47        snippet: String,
48    },
49
50    /// TLS certificate information.
51    Certificate {
52        /// Certificate subject (CN).
53        subject: String,
54        /// Subject Alternative Names.
55        san: Vec<String>,
56        /// Certificate issuer.
57        issuer: String,
58        /// Expiration date.
59        expires: String,
60    },
61
62    /// Source code snippet (for SAST, malware detection).
63    CodeSnippet {
64        /// File path.
65        file: String,
66        /// Line number.
67        line: usize,
68        /// Column number (optional).
69        column: Option<usize>,
70        /// The matched code.
71        snippet: String,
72        /// Programming language.
73        language: Option<String>,
74    },
75
76    /// HTTP request that triggered the finding (for template/vuln scanners).
77    HttpRequest {
78        /// HTTP method.
79        method: String,
80        /// Full URL.
81        url: String,
82        /// Request headers.
83        headers: Vec<(String, String)>,
84        /// Request body.
85        body: Option<String>,
86    },
87
88    /// Matched pattern or regex (for pattern-based scanners).
89    PatternMatch {
90        /// The pattern or regex that matched.
91        pattern: String,
92        /// The matched content.
93        matched: String,
94    },
95
96    /// Unstructured evidence — fallback for anything that doesn't fit above.
97    Raw(String),
98}
99
100impl Evidence {
101    /// Create an HTTP response evidence with just a status code.
102    #[must_use]
103    pub fn http_status(status: u16) -> Self {
104        Self::HttpResponse {
105            status,
106            headers: vec![],
107            body_excerpt: None,
108        }
109    }
110
111    /// Create a code snippet evidence.
112    #[must_use]
113    pub fn code(file: impl Into<String>, line: usize, snippet: impl Into<String>) -> Self {
114        Self::CodeSnippet {
115            file: file.into(),
116            line,
117            column: None,
118            snippet: snippet.into(),
119            language: None,
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn serde_tagged() {
130        let ev = Evidence::HttpResponse {
131            status: 403,
132            headers: vec![("server".into(), "cloudflare".into())],
133            body_excerpt: Some("blocked".into()),
134        };
135        let json = serde_json::to_value(&ev).unwrap();
136        assert_eq!(json["type"], "http_response");
137        assert_eq!(json["status"], 403);
138    }
139
140    #[test]
141    fn code_snippet_roundtrip() {
142        let ev = Evidence::code("src/main.rs", 42, "let key = \"AKIA...\";");
143        let json = serde_json::to_string(&ev).unwrap();
144        let back: Evidence = serde_json::from_str(&json).unwrap();
145        if let Evidence::CodeSnippet {
146            file,
147            line,
148            snippet,
149            ..
150        } = back
151        {
152            assert_eq!(file, "src/main.rs");
153            assert_eq!(line, 42);
154            assert!(snippet.contains("AKIA"));
155        } else {
156            panic!("wrong variant");
157        }
158    }
159
160    #[test]
161    fn helper_constructors_roundtrip() {
162        let ev = Evidence::http_status(201);
163        let json = serde_json::to_string(&ev).unwrap();
164        let back: Evidence = serde_json::from_str(&json).unwrap();
165        if let Evidence::HttpResponse {
166            status,
167            headers,
168            body_excerpt,
169        } = back
170        {
171            assert_eq!(status, 201);
172            assert!(headers.is_empty());
173            assert!(body_excerpt.is_none());
174        } else {
175            panic!("wrong variant");
176        }
177
178        let snippet = Evidence::code("lib.rs", 10, "secret = 'x'");
179        let json = serde_json::to_string(&snippet).unwrap();
180        let back: Evidence = serde_json::from_str(&json).unwrap();
181        if let Evidence::CodeSnippet { line, snippet, .. } = back {
182            assert_eq!(line, 10);
183            assert!(snippet.contains("secret"));
184        } else {
185            panic!("wrong variant");
186        }
187    }
188
189    #[test]
190    fn serde_multiple_evidence_variants() {
191        let samples = vec![
192            Evidence::HttpRequest {
193                method: "GET".into(),
194                url: "https://example.com/login".into(),
195                headers: vec![("host".into(), "example.com".into())],
196                body: Some("a=1".into()),
197            },
198            Evidence::Certificate {
199                subject: "CN=example".into(),
200                san: vec!["DNS:example.com".into()],
201                issuer: "Let's Encrypt".into(),
202                expires: "2028-01-01".into(),
203            },
204            Evidence::PatternMatch {
205                pattern: "api_key=[A-Za-z]+".into(),
206                matched: "api_key=abc".into(),
207            },
208        ];
209
210        for sample in samples {
211            let json = serde_json::to_string(&sample).unwrap();
212            let back: Evidence = serde_json::from_str(&json).unwrap();
213            match (sample, back) {
214                (
215                    Evidence::HttpRequest { method: m1, .. },
216                    Evidence::HttpRequest { method: m2, .. },
217                ) => {
218                    assert_eq!(m1, m2);
219                }
220                (
221                    Evidence::Certificate { subject: s1, .. },
222                    Evidence::Certificate { subject: s2, .. },
223                ) => {
224                    assert_eq!(s1, s2);
225                }
226                (
227                    Evidence::PatternMatch { pattern: p1, .. },
228                    Evidence::PatternMatch { pattern: p2, .. },
229                ) => {
230                    assert_eq!(p1, p2);
231                }
232                _ => panic!("roundtrip mismatch"),
233            }
234        }
235    }
236}