use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FalsePositiveReport {
pub rule_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub matched_pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub anonymous_id: Option<String>,
pub version: String,
pub reported_at: String,
}
impl FalsePositiveReport {
pub fn new(rule_id: impl Into<String>) -> Self {
Self {
rule_id: rule_id.into(),
extension: None,
matched_pattern: None,
description: None,
anonymous_id: None,
version: env!("CARGO_PKG_VERSION").to_string(),
reported_at: chrono::Utc::now().to_rfc3339(),
}
}
pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
self.extension = Some(ext.into());
self
}
pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
self.matched_pattern = Some(Self::redact_pattern(&pattern.into()));
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn with_anonymous_id(mut self, seed: impl AsRef<[u8]>) -> Self {
let mut hasher = Sha256::new();
hasher.update(seed.as_ref());
let hash = hasher.finalize();
self.anonymous_id = Some(format!("{:x}", hash)[..16].to_string());
self
}
fn redact_pattern(pattern: &str) -> String {
let redacted = pattern
.chars()
.map(|c| {
if c.is_alphanumeric() && pattern.len() > 20 {
'*'
} else {
c
}
})
.collect::<String>();
if redacted.len() > 50 {
format!("{}...", &redacted[..47])
} else {
redacted
}
}
pub fn to_github_issue_body(&self) -> String {
let mut body = String::new();
body.push_str("## False Positive Report\n\n");
body.push_str(&format!("**Rule ID:** `{}`\n", self.rule_id));
body.push_str(&format!("**Version:** `{}`\n", self.version));
if let Some(ref ext) = self.extension {
body.push_str(&format!("**File Extension:** `.{}`\n", ext));
}
if let Some(ref pattern) = self.matched_pattern {
body.push_str(&format!("**Pattern (redacted):** `{}`\n", pattern));
}
body.push_str("\n### Description\n\n");
if let Some(ref desc) = self.description {
body.push_str(desc);
} else {
body.push_str("_No description provided._");
}
body.push_str("\n\n---\n");
body.push_str(&format!("Reported at: {}\n", self.reported_at));
if let Some(ref anon_id) = self.anonymous_id {
body.push_str(&format!("Anonymous ID: `{}`\n", anon_id));
}
body.push_str("\n_Generated by cc-audit --report-fp_\n");
body
}
pub fn to_github_issue_title(&self) -> String {
let mut title = format!("[FP] {}", self.rule_id);
if let Some(ref ext) = self.extension {
title.push_str(&format!(" in .{} files", ext));
}
title
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_report() {
let report = FalsePositiveReport::new("SL-001");
assert_eq!(report.rule_id, "SL-001");
assert_eq!(report.version, env!("CARGO_PKG_VERSION"));
assert!(report.extension.is_none());
assert!(report.description.is_none());
}
#[test]
fn test_builder_pattern() {
let report = FalsePositiveReport::new("SL-002")
.with_extension("py")
.with_description("This is a test API key in fixtures");
assert_eq!(report.rule_id, "SL-002");
assert_eq!(report.extension, Some("py".to_string()));
assert_eq!(
report.description,
Some("This is a test API key in fixtures".to_string())
);
}
#[test]
fn test_anonymous_id() {
let report1 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine1");
let report2 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine1");
let report3 = FalsePositiveReport::new("SL-001").with_anonymous_id("machine2");
assert_eq!(report1.anonymous_id, report2.anonymous_id);
assert_ne!(report1.anonymous_id, report3.anonymous_id);
}
#[test]
fn test_redact_pattern() {
let short = FalsePositiveReport::redact_pattern("ABC123");
assert_eq!(short, "ABC123");
let long = FalsePositiveReport::redact_pattern("sk_test_1234567890abcdefghijklmnop");
assert!(long.contains('*'));
}
#[test]
fn test_github_issue_body() {
let report = FalsePositiveReport::new("SL-001")
.with_extension("js")
.with_description("Test fixture file");
let body = report.to_github_issue_body();
assert!(body.contains("SL-001"));
assert!(body.contains(".js"));
assert!(body.contains("Test fixture file"));
}
#[test]
fn test_github_issue_title() {
let report = FalsePositiveReport::new("SL-003").with_extension("ts");
let title = report.to_github_issue_title();
assert_eq!(title, "[FP] SL-003 in .ts files");
}
#[test]
fn test_github_issue_title_without_extension() {
let report = FalsePositiveReport::new("SL-003");
let title = report.to_github_issue_title();
assert_eq!(title, "[FP] SL-003");
}
#[test]
fn test_github_issue_body_without_description() {
let report = FalsePositiveReport::new("SL-001");
let body = report.to_github_issue_body();
assert!(body.contains("_No description provided._"));
}
#[test]
fn test_github_issue_body_with_pattern() {
let report = FalsePositiveReport::new("SL-001").with_pattern("short");
let body = report.to_github_issue_body();
assert!(body.contains("Pattern (redacted)"));
}
#[test]
fn test_github_issue_body_with_anonymous_id() {
let report = FalsePositiveReport::new("SL-001").with_anonymous_id("test-seed");
let body = report.to_github_issue_body();
assert!(body.contains("Anonymous ID:"));
}
#[test]
fn test_with_pattern() {
let report = FalsePositiveReport::new("SL-001").with_pattern("secret_value");
assert!(report.matched_pattern.is_some());
}
#[test]
fn test_redact_pattern_very_long() {
let long = "a".repeat(100);
let redacted = FalsePositiveReport::redact_pattern(&long);
assert!(redacted.len() <= 50);
assert!(redacted.ends_with("..."));
}
#[test]
fn test_redact_pattern_with_special_chars() {
let pattern = "sk_test_abc-123_xyz!@#";
let redacted = FalsePositiveReport::redact_pattern(pattern);
assert!(redacted.contains('-') || redacted.contains('_'));
}
#[test]
fn test_report_serialization() {
let report = FalsePositiveReport::new("SL-001")
.with_extension("js")
.with_description("Test");
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("SL-001"));
assert!(json.contains("js"));
}
#[test]
fn test_report_deserialization() {
let json = r#"{
"rule_id": "SL-002",
"version": "1.0.0",
"reported_at": "2024-01-01T00:00:00Z"
}"#;
let report: FalsePositiveReport = serde_json::from_str(json).unwrap();
assert_eq!(report.rule_id, "SL-002");
assert_eq!(report.version, "1.0.0");
}
#[test]
fn test_report_clone() {
let report = FalsePositiveReport::new("SL-001")
.with_extension("py")
.with_description("Test");
let cloned = report.clone();
assert_eq!(cloned.rule_id, report.rule_id);
assert_eq!(cloned.extension, report.extension);
assert_eq!(cloned.description, report.description);
}
#[test]
fn test_report_debug() {
let report = FalsePositiveReport::new("SL-001");
let debug_str = format!("{:?}", report);
assert!(debug_str.contains("FalsePositiveReport"));
assert!(debug_str.contains("SL-001"));
}
#[test]
fn test_anonymous_id_length() {
let report = FalsePositiveReport::new("SL-001").with_anonymous_id("any-seed");
assert_eq!(report.anonymous_id.as_ref().unwrap().len(), 16);
}
}