Skip to main content

cc_audit/feedback/
report.rs

1//! False positive report data structures.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6/// A false positive report for submission.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct FalsePositiveReport {
9    /// The rule ID that triggered the false positive (e.g., "SL-001")
10    pub rule_id: String,
11
12    /// File extension where the false positive occurred (optional, for patterns)
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub extension: Option<String>,
15
16    /// The pattern that matched (optional, redacted for privacy)
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub matched_pattern: Option<String>,
19
20    /// User description of why this is a false positive
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub description: Option<String>,
23
24    /// Anonymous identifier (SHA256 hash) for deduplication
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub anonymous_id: Option<String>,
27
28    /// cc-audit version
29    pub version: String,
30
31    /// Report timestamp (ISO 8601)
32    pub reported_at: String,
33}
34
35impl FalsePositiveReport {
36    /// Create a new false positive report.
37    pub fn new(rule_id: impl Into<String>) -> Self {
38        Self {
39            rule_id: rule_id.into(),
40            extension: None,
41            matched_pattern: None,
42            description: None,
43            anonymous_id: None,
44            version: env!("CARGO_PKG_VERSION").to_string(),
45            reported_at: chrono::Utc::now().to_rfc3339(),
46        }
47    }
48
49    /// Set the file extension.
50    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
51        self.extension = Some(ext.into());
52        self
53    }
54
55    /// Set the matched pattern (will be redacted for privacy).
56    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
57        self.matched_pattern = Some(Self::redact_pattern(&pattern.into()));
58        self
59    }
60
61    /// Set the user description.
62    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
63        self.description = Some(desc.into());
64        self
65    }
66
67    /// Generate and set an anonymous ID from machine-specific data.
68    pub fn with_anonymous_id(mut self, seed: impl AsRef<[u8]>) -> Self {
69        let mut hasher = Sha256::new();
70        hasher.update(seed.as_ref());
71        let hash = hasher.finalize();
72        self.anonymous_id = Some(format!("{:x}", hash)[..16].to_string());
73        self
74    }
75
76    /// Redact sensitive parts of a pattern for privacy.
77    fn redact_pattern(pattern: &str) -> String {
78        // Redact actual secret values, keep only pattern structure
79        let redacted = pattern
80            .chars()
81            .map(|c| {
82                if c.is_alphanumeric() && pattern.len() > 20 {
83                    '*'
84                } else {
85                    c
86                }
87            })
88            .collect::<String>();
89
90        // Limit length
91        if redacted.len() > 50 {
92            format!("{}...", &redacted[..47])
93        } else {
94            redacted
95        }
96    }
97
98    /// Format the report as a GitHub Issue body.
99    pub fn to_github_issue_body(&self) -> String {
100        let mut body = String::new();
101
102        body.push_str("## False Positive Report\n\n");
103
104        body.push_str(&format!("**Rule ID:** `{}`\n", self.rule_id));
105        body.push_str(&format!("**Version:** `{}`\n", self.version));
106
107        if let Some(ref ext) = self.extension {
108            body.push_str(&format!("**File Extension:** `.{}`\n", ext));
109        }
110
111        if let Some(ref pattern) = self.matched_pattern {
112            body.push_str(&format!("**Pattern (redacted):** `{}`\n", pattern));
113        }
114
115        body.push_str("\n### Description\n\n");
116        if let Some(ref desc) = self.description {
117            body.push_str(desc);
118        } else {
119            body.push_str("_No description provided._");
120        }
121
122        body.push_str("\n\n---\n");
123        body.push_str(&format!("Reported at: {}\n", self.reported_at));
124
125        if let Some(ref anon_id) = self.anonymous_id {
126            body.push_str(&format!("Anonymous ID: `{}`\n", anon_id));
127        }
128
129        body.push_str("\n_Generated by cc-audit --report-fp_\n");
130
131        body
132    }
133
134    /// Format the report as a GitHub Issue title.
135    pub fn to_github_issue_title(&self) -> String {
136        let mut title = format!("[FP] {}", self.rule_id);
137
138        if let Some(ref ext) = self.extension {
139            title.push_str(&format!(" in .{} files", ext));
140        }
141
142        title
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_new_report() {
152        let report = FalsePositiveReport::new("SL-001");
153        assert_eq!(report.rule_id, "SL-001");
154        assert_eq!(report.version, env!("CARGO_PKG_VERSION"));
155        assert!(report.extension.is_none());
156        assert!(report.description.is_none());
157    }
158
159    #[test]
160    fn test_builder_pattern() {
161        let report = FalsePositiveReport::new("SL-002")
162            .with_extension("py")
163            .with_description("This is a test API key in fixtures");
164
165        assert_eq!(report.rule_id, "SL-002");
166        assert_eq!(report.extension, Some("py".to_string()));
167        assert_eq!(
168            report.description,
169            Some("This is a test API key in fixtures".to_string())
170        );
171    }
172
173    #[test]
174    fn test_anonymous_id() {
175        let report1 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine1");
176        let report2 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine1");
177        let report3 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine2");
178
179        // Same seed should produce same ID
180        assert_eq!(report1.anonymous_id, report2.anonymous_id);
181
182        // Different seed should produce different ID
183        assert_ne!(report1.anonymous_id, report3.anonymous_id);
184    }
185
186    #[test]
187    fn test_redact_pattern() {
188        // Short patterns are not redacted
189        let short = FalsePositiveReport::redact_pattern("ABC123");
190        assert_eq!(short, "ABC123");
191
192        // Long patterns are redacted
193        let long = FalsePositiveReport::redact_pattern("sk_test_1234567890abcdefghijklmnop");
194        assert!(long.contains('*'));
195    }
196
197    #[test]
198    fn test_github_issue_body() {
199        let report = FalsePositiveReport::new("SL-001")
200            .with_extension("js")
201            .with_description("Test fixture file");
202
203        let body = report.to_github_issue_body();
204
205        assert!(body.contains("SL-001"));
206        assert!(body.contains(".js"));
207        assert!(body.contains("Test fixture file"));
208    }
209
210    #[test]
211    fn test_github_issue_title() {
212        let report = FalsePositiveReport::new("SL-003").with_extension("ts");
213        let title = report.to_github_issue_title();
214
215        assert_eq!(title, "[FP] SL-003 in .ts files");
216    }
217
218    #[test]
219    fn test_github_issue_title_without_extension() {
220        let report = FalsePositiveReport::new("SL-003");
221        let title = report.to_github_issue_title();
222
223        assert_eq!(title, "[FP] SL-003");
224    }
225
226    #[test]
227    fn test_github_issue_body_without_description() {
228        let report = FalsePositiveReport::new("SL-001");
229
230        let body = report.to_github_issue_body();
231
232        assert!(body.contains("_No description provided._"));
233    }
234
235    #[test]
236    fn test_github_issue_body_with_pattern() {
237        let report = FalsePositiveReport::new("SL-001").with_pattern("short");
238
239        let body = report.to_github_issue_body();
240
241        assert!(body.contains("Pattern (redacted)"));
242    }
243
244    #[test]
245    fn test_github_issue_body_with_anonymous_id() {
246        let report = FalsePositiveReport::new("SL-001").with_anonymous_id("test-seed");
247
248        let body = report.to_github_issue_body();
249
250        assert!(body.contains("Anonymous ID:"));
251    }
252
253    #[test]
254    fn test_with_pattern() {
255        let report = FalsePositiveReport::new("SL-001").with_pattern("secret_value");
256
257        assert!(report.matched_pattern.is_some());
258    }
259
260    #[test]
261    fn test_redact_pattern_very_long() {
262        // Very long pattern should be truncated
263        let long = "a".repeat(100);
264        let redacted = FalsePositiveReport::redact_pattern(&long);
265        assert!(redacted.len() <= 50);
266        assert!(redacted.ends_with("..."));
267    }
268
269    #[test]
270    fn test_redact_pattern_with_special_chars() {
271        // Pattern with special characters
272        let pattern = "sk_test_abc-123_xyz!@#";
273        let redacted = FalsePositiveReport::redact_pattern(pattern);
274        // Special chars should be preserved
275        assert!(redacted.contains('-') || redacted.contains('_'));
276    }
277
278    #[test]
279    fn test_report_serialization() {
280        let report = FalsePositiveReport::new("SL-001")
281            .with_extension("js")
282            .with_description("Test");
283
284        let json = serde_json::to_string(&report).unwrap();
285        assert!(json.contains("SL-001"));
286        assert!(json.contains("js"));
287    }
288
289    #[test]
290    fn test_report_deserialization() {
291        let json = r#"{
292            "rule_id": "SL-002",
293            "version": "1.0.0",
294            "reported_at": "2024-01-01T00:00:00Z"
295        }"#;
296
297        let report: FalsePositiveReport = serde_json::from_str(json).unwrap();
298        assert_eq!(report.rule_id, "SL-002");
299        assert_eq!(report.version, "1.0.0");
300    }
301
302    #[test]
303    fn test_report_clone() {
304        let report = FalsePositiveReport::new("SL-001")
305            .with_extension("py")
306            .with_description("Test");
307
308        let cloned = report.clone();
309
310        assert_eq!(cloned.rule_id, report.rule_id);
311        assert_eq!(cloned.extension, report.extension);
312        assert_eq!(cloned.description, report.description);
313    }
314
315    #[test]
316    fn test_report_debug() {
317        let report = FalsePositiveReport::new("SL-001");
318        let debug_str = format!("{:?}", report);
319
320        assert!(debug_str.contains("FalsePositiveReport"));
321        assert!(debug_str.contains("SL-001"));
322    }
323
324    #[test]
325    fn test_anonymous_id_length() {
326        let report = FalsePositiveReport::new("SL-001").with_anonymous_id("any-seed");
327
328        // Should be 16 characters (first 16 hex digits of SHA256)
329        assert_eq!(report.anonymous_id.as_ref().unwrap().len(), 16);
330    }
331}