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, PartialEq, Eq, 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    pub fn http_status(status: u16) -> Result<Self, &'static str> {
103        if !(100..=599).contains(&status) {
104            return Err(
105                "HTTP status code must be between 100 and 599. Fix: pass a valid RFC HTTP status code.",
106            );
107        }
108        Ok(Self::HttpResponse {
109            status,
110            headers: vec![],
111            body_excerpt: None,
112        })
113    }
114
115    /// Create a code snippet evidence.
116    #[must_use]
117    pub fn code(
118        file: impl Into<String>,
119        line: usize,
120        snippet: impl Into<String>,
121        column: Option<usize>,
122        language: Option<String>,
123    ) -> Self {
124        Self::CodeSnippet {
125            file: file.into(),
126            line,
127            column,
128            snippet: snippet.into(),
129            language,
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn serde_tagged() {
140        let ev = Evidence::HttpResponse {
141            status: 403,
142            headers: vec![("server".into(), "cloudflare".into())],
143            body_excerpt: Some("blocked".into()),
144        };
145        let json = serde_json::to_value(&ev).unwrap();
146        assert_eq!(json["type"], "http_response");
147        assert_eq!(json["status"], 403);
148    }
149
150    #[test]
151    fn code_snippet_roundtrip() {
152        let ev = Evidence::code("src/main.rs", 42, "let key = \"AKIA...\";", None, None);
153        let json = serde_json::to_string(&ev).unwrap();
154        let back: Evidence = serde_json::from_str(&json).unwrap();
155        if let Evidence::CodeSnippet {
156            file,
157            line,
158            snippet,
159            ..
160        } = back
161        {
162            assert_eq!(file, "src/main.rs");
163            assert_eq!(line, 42);
164            assert_eq!(snippet, "let key = \"AKIA...\";");
165        } else {
166            panic!("wrong variant");
167        }
168    }
169
170    #[test]
171    fn helper_constructors_roundtrip() {
172        let ev = Evidence::http_status(201).unwrap();
173        let json = serde_json::to_string(&ev).unwrap();
174        let back: Evidence = serde_json::from_str(&json).unwrap();
175        if let Evidence::HttpResponse {
176            status,
177            headers,
178            body_excerpt,
179        } = back
180        {
181            assert_eq!(status, 201);
182            assert!(headers.is_empty());
183            assert!(body_excerpt.is_none());
184        } else {
185            panic!("wrong variant");
186        }
187
188        let snippet = Evidence::code("lib.rs", 10, "secret = 'x'", None, None);
189        let json = serde_json::to_string(&snippet).unwrap();
190        let back: Evidence = serde_json::from_str(&json).unwrap();
191        if let Evidence::CodeSnippet { line, snippet, .. } = back {
192            assert_eq!(line, 10);
193            assert!(snippet.contains("secret"));
194        } else {
195            panic!("wrong variant");
196        }
197    }
198
199    #[test]
200    fn serde_multiple_evidence_variants() {
201        let samples = vec![
202            Evidence::HttpRequest {
203                method: "GET".into(),
204                url: "https://example.com/login".into(),
205                headers: vec![("host".into(), "example.com".into())],
206                body: Some("a=1".into()),
207            },
208            Evidence::Certificate {
209                subject: "CN=example".into(),
210                san: vec!["DNS:example.com".into()],
211                issuer: "Let's Encrypt".into(),
212                expires: "2028-01-01".into(),
213            },
214            Evidence::PatternMatch {
215                pattern: "api_key=[A-Za-z]+".into(),
216                matched: "api_key=abc".into(),
217            },
218        ];
219
220        for sample in samples {
221            let json = serde_json::to_string(&sample).unwrap();
222            let back: Evidence = serde_json::from_str(&json).unwrap();
223            match (sample, back) {
224                (
225                    Evidence::HttpRequest { method: m1, .. },
226                    Evidence::HttpRequest { method: m2, .. },
227                ) => {
228                    assert_eq!(m1, m2);
229                }
230                (
231                    Evidence::Certificate { subject: s1, .. },
232                    Evidence::Certificate { subject: s2, .. },
233                ) => {
234                    assert_eq!(s1, s2);
235                }
236                (
237                    Evidence::PatternMatch { pattern: p1, .. },
238                    Evidence::PatternMatch { pattern: p2, .. },
239                ) => {
240                    assert_eq!(p1, p2);
241                }
242                _ => panic!("roundtrip mismatch"),
243            }
244        }
245    }
246}