cc-audit 3.2.14

Security auditor for Claude Code skills, hooks, and MCP servers
Documentation
//! False positive report data structures.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

/// A false positive report for submission.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FalsePositiveReport {
    /// The rule ID that triggered the false positive (e.g., "SL-001")
    pub rule_id: String,

    /// File extension where the false positive occurred (optional, for patterns)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extension: Option<String>,

    /// The pattern that matched (optional, redacted for privacy)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub matched_pattern: Option<String>,

    /// User description of why this is a false positive
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Anonymous identifier (SHA256 hash) for deduplication
    #[serde(skip_serializing_if = "Option::is_none")]
    pub anonymous_id: Option<String>,

    /// cc-audit version
    pub version: String,

    /// Report timestamp (ISO 8601)
    pub reported_at: String,
}

impl FalsePositiveReport {
    /// Create a new false positive report.
    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(),
        }
    }

    /// Set the file extension.
    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
        self.extension = Some(ext.into());
        self
    }

    /// Set the matched pattern (will be redacted for privacy).
    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
        self.matched_pattern = Some(Self::redact_pattern(&pattern.into()));
        self
    }

    /// Set the user description.
    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
        self.description = Some(desc.into());
        self
    }

    /// Generate and set an anonymous ID from machine-specific data.
    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
    }

    /// Redact sensitive parts of a pattern for privacy.
    fn redact_pattern(pattern: &str) -> String {
        // Redact actual secret values, keep only pattern structure
        let redacted = pattern
            .chars()
            .map(|c| {
                if c.is_alphanumeric() && pattern.len() > 20 {
                    '*'
                } else {
                    c
                }
            })
            .collect::<String>();

        // Limit length
        if redacted.len() > 50 {
            format!("{}...", &redacted[..47])
        } else {
            redacted
        }
    }

    /// Format the report as a GitHub Issue body.
    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
    }

    /// Format the report as a GitHub Issue title.
    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");

        // Same seed should produce same ID
        assert_eq!(report1.anonymous_id, report2.anonymous_id);

        // Different seed should produce different ID
        assert_ne!(report1.anonymous_id, report3.anonymous_id);
    }

    #[test]
    fn test_redact_pattern() {
        // Short patterns are not redacted
        let short = FalsePositiveReport::redact_pattern("ABC123");
        assert_eq!(short, "ABC123");

        // Long patterns are redacted
        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() {
        // Very long pattern should be truncated
        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() {
        // Pattern with special characters
        let pattern = "sk_test_abc-123_xyz!@#";
        let redacted = FalsePositiveReport::redact_pattern(pattern);
        // Special chars should be preserved
        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");

        // Should be 16 characters (first 16 hex digits of SHA256)
        assert_eq!(report.anonymous_id.as_ref().unwrap().len(), 16);
    }
}