cc_audit/feedback/
report.rs1use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct FalsePositiveReport {
9 pub rule_id: String,
11
12 #[serde(skip_serializing_if = "Option::is_none")]
14 pub extension: Option<String>,
15
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub matched_pattern: Option<String>,
19
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub description: Option<String>,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub anonymous_id: Option<String>,
27
28 pub version: String,
30
31 pub reported_at: String,
33}
34
35impl FalsePositiveReport {
36 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 pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
51 self.extension = Some(ext.into());
52 self
53 }
54
55 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
63 self.description = Some(desc.into());
64 self
65 }
66
67 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 fn redact_pattern(pattern: &str) -> String {
78 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 if redacted.len() > 50 {
92 format!("{}...", &redacted[..47])
93 } else {
94 redacted
95 }
96 }
97
98 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 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 assert_eq!(report1.anonymous_id, report2.anonymous_id);
181
182 assert_ne!(report1.anonymous_id, report3.anonymous_id);
184 }
185
186 #[test]
187 fn test_redact_pattern() {
188 let short = FalsePositiveReport::redact_pattern("ABC123");
190 assert_eq!(short, "ABC123");
191
192 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 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 let pattern = "sk_test_abc-123_xyz!@#";
273 let redacted = FalsePositiveReport::redact_pattern(pattern);
274 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 assert_eq!(report.anonymous_id.as_ref().unwrap().len(), 16);
330 }
331}