forge-guard 0.3.6

Pre-deployment smart contract auditing framework for Foundry
Documentation
//! Suppression file support.
//!
//! Lets teams permanently suppress known false positives and accepted risks
//! via a `.forge-guard-suppressions` file. Each line references a finding ID
//! (prefix match), optionally scoped to a specific file:
//!
//! ```text
//! FA-H-001 # Known false positive in Vault.sol
//! FA-M-004 src/oracle/PriceFeed.sol
//! ```

use crate::core::Finding;
use anyhow::{Context, Result};
use serde::Serialize;

/// A single suppression entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Suppression {
    /// Finding ID or ID prefix to suppress (e.g. `FA-H-001`).
    pub id: String,
    /// Optional file scope — only findings in this file are suppressed.
    pub file: Option<String>,
    /// Optional comment explaining why the finding is suppressed.
    pub comment: Option<String>,
}

/// Parse suppression file content into entries.
///
/// Blank lines and lines starting with `#` are ignored. The first
/// whitespace-separated token is the finding ID; an optional second token
/// (before any `#`) is the file scope; text after `#` is the comment.
pub fn parse(content: &str) -> Vec<Suppression> {
    let mut out = Vec::new();
    for raw_line in content.lines() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        // Split off the comment (also tolerate `;` as an alternative).
        let (code, comment) = match line.find(['#', ';']) {
            Some(pos) => (line[..pos].trim(), Some(line[pos + 1..].trim().to_string())),
            None => (line, None),
        };
        let mut tokens = code.split_whitespace();
        let Some(id) = tokens.next() else { continue };
        if id.is_empty() {
            continue;
        }
        let file = tokens
            .next()
            .map(str::trim)
            .filter(|f| !f.is_empty())
            .map(String::from);
        out.push(Suppression {
            id: id.to_string(),
            file,
            comment: comment.filter(|c| !c.is_empty()),
        });
    }
    out
}

/// Load suppressions from a file path. Missing files yield an empty list.
pub fn load(path: &std::path::Path) -> Result<Vec<Suppression>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Could not read suppression file {}", path.display()))?;
    Ok(parse(&content))
}

/// Whether a suppression entry matches a finding.
///
/// The entry ID is a prefix match against the finding ID (e.g. `FA-H-001`
/// matches `FA-H-001-1`). When the entry has a file scope, the finding's
/// file must match it (either exactly or as a suffix).
pub fn entry_matches(entry: &Suppression, finding: &Finding) -> bool {
    let id_matches = finding.id == entry.id || finding.id.starts_with(&format!("{}-", entry.id));
    if !id_matches {
        return false;
    }
    match &entry.file {
        None => true,
        Some(scope) => match &finding.file {
            None => false,
            Some(file) => file == scope || file.ends_with(&format!("/{}", scope)),
        },
    }
}

/// Whether any suppression entry matches the finding.
pub fn is_suppressed(finding: &Finding, suppressions: &[Suppression]) -> bool {
    suppressions.iter().any(|e| entry_matches(e, finding))
}

/// Derive the check ID from a finding ID (`FA-H-001-3` → `FA-H-001`).
fn check_id(finding: &Finding) -> String {
    match finding.id.rfind('-') {
        Some(pos) if pos > 0 => finding.id[..pos].to_string(),
        _ => finding.id.clone(),
    }
}

/// Generate suppression file content from a set of findings.
///
/// Entries are deduplicated by check ID and sorted; a comment carries the
/// finding title and location for human reference.
pub fn generate(findings: &[Finding]) -> String {
    let mut by_check: std::collections::BTreeMap<String, &Finding> =
        std::collections::BTreeMap::new();
    for f in findings {
        by_check.entry(check_id(f)).or_insert(f);
    }

    let mut out = String::new();
    out.push_str(
        "# Forge Guard suppressions — generated by `forge-guard audit --generate-suppressions`\n",
    );
    out.push_str("# Format: <FINDING_ID> [file] # comment\n");
    out.push_str(
        "# Findings listed here are excluded from scoring unless --show-suppressed is used.\n\n",
    );
    for (id, f) in by_check {
        let location = match (&f.file, f.line) {
            (Some(file), Some(line)) => format!("{}:{}", file, line),
            (Some(file), None) => file.clone(),
            (None, _) => String::new(),
        };
        let comment = if location.is_empty() {
            f.title.to_string()
        } else {
            format!("{} ({})", f.title, location)
        };
        out.push_str(&format!("{} # {}\n", id, comment));
    }
    out
}

/// Write a suppression file from findings (used by `--generate-suppressions`).
pub fn write(path: &std::path::Path, findings: &[Finding]) -> Result<()> {
    let content = generate(findings);
    std::fs::write(path, content)
        .with_context(|| format!("Could not write suppression file {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn finding(id: &str, file: &str, line: usize) -> Finding {
        Finding::builder()
            .id(id)
            .title("Some finding")
            .description("desc")
            .severity(crate::core::Severity::High)
            .file(file)
            .location(line, 0)
            .recommendation("fix")
            .category("Security")
            .build()
    }

    #[test]
    fn test_parse_basic() {
        let entries = parse(
            "FA-H-001 # Known false positive in Vault.sol\nFA-M-004 src/oracle/PriceFeed.sol\n\n# full comment line\nFA-L-002\n",
        );
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].id, "FA-H-001");
        assert_eq!(entries[0].file, None);
        assert_eq!(
            entries[0].comment.as_deref(),
            Some("Known false positive in Vault.sol")
        );
        assert_eq!(entries[1].id, "FA-M-004");
        assert_eq!(entries[1].file.as_deref(), Some("src/oracle/PriceFeed.sol"));
        assert_eq!(entries[2].id, "FA-L-002");
        assert_eq!(entries[2].comment, None);
    }

    #[test]
    fn test_parse_ignores_blank_and_comments() {
        assert_eq!(parse(""), Vec::<Suppression>::new());
        assert_eq!(parse("# only a comment\n\n  \n"), Vec::<Suppression>::new());
    }

    #[test]
    fn test_parse_semicolon_comment() {
        let entries = parse("FA-H-001 ; semicolon comment");
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].comment.as_deref(), Some("semicolon comment"));
    }

    #[test]
    fn test_entry_matches_prefix() {
        let entry = Suppression {
            id: "FA-H-001".into(),
            file: None,
            comment: None,
        };
        assert!(entry_matches(
            &entry,
            &finding("FA-H-001-1", "Vault.sol", 42)
        ));
        assert!(entry_matches(&entry, &finding("FA-H-001", "Vault.sol", 42)));
        assert!(!entry_matches(
            &entry,
            &finding("FA-H-002-1", "Vault.sol", 42)
        ));
        assert!(!entry_matches(
            &entry,
            &finding("FA-M-001-1", "Vault.sol", 42)
        ));
    }

    #[test]
    fn test_entry_matches_file_scope() {
        let entry = Suppression {
            id: "FA-H-001".into(),
            file: Some("src/Vault.sol".into()),
            comment: None,
        };
        assert!(entry_matches(
            &entry,
            &finding("FA-H-001-1", "src/Vault.sol", 42)
        ));
        assert!(entry_matches(
            &entry,
            &finding("FA-H-001-1", "contracts/src/Vault.sol", 42)
        ));
        assert!(!entry_matches(
            &entry,
            &finding("FA-H-001-1", "src/Other.sol", 42)
        ));
        assert!(!entry_matches(
            &entry,
            &finding("FA-H-001-1", "Other.sol", 42)
        ));
    }

    #[test]
    fn test_is_suppressed() {
        let suppressions = parse("FA-H-001\nFA-M-003 src/Oracle.sol");
        let f1 = finding("FA-H-001-2", "Vault.sol", 10);
        let f2 = finding("FA-M-003-1", "src/Oracle.sol", 20);
        let f3 = finding("FA-M-003-1", "src/Other.sol", 20);
        let f4 = finding("FA-L-009-1", "Vault.sol", 30);
        assert!(is_suppressed(&f1, &suppressions));
        assert!(is_suppressed(&f2, &suppressions));
        assert!(!is_suppressed(&f3, &suppressions));
        assert!(!is_suppressed(&f4, &suppressions));
        assert!(!is_suppressed(&f1, &[]));
    }

    #[test]
    fn test_generate_deduplicates_and_sorts() {
        let findings = vec![
            finding("FA-H-003-1", "B.sol", 1),
            finding("FA-H-001-1", "A.sol", 1),
            finding("FA-H-001-2", "A.sol", 2),
            finding("FA-M-002-1", "C.sol", 3),
        ];
        let content = generate(&findings);
        assert!(content.contains("FA-H-001 # Some finding (A.sol:1)"));
        assert!(content.contains("FA-H-003"));
        assert!(content.contains("FA-M-002"));
        // FA-H-001 appears exactly once despite two findings
        assert_eq!(content.matches("FA-H-001").count(), 1);
        // Sorted: FA-H-001 before FA-H-003 before FA-M-002
        let i1 = content.find("FA-H-001").unwrap();
        let i3 = content.find("FA-H-003").unwrap();
        let i2 = content.find("FA-M-002").unwrap();
        assert!(i1 < i3 && i3 < i2);
    }

    #[test]
    fn test_generate_roundtrip() {
        let findings = vec![finding("FA-H-001-1", "Vault.sol", 42)];
        let content = generate(&findings);
        let entries = parse(&content);
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].id, "FA-H-001");
        assert!(is_suppressed(&findings[0], &entries));
    }

    #[test]
    fn test_load_missing_file_empty() {
        assert!(
            load(std::path::Path::new("/nonexistent/definitely-missing"))
                .unwrap()
                .is_empty()
        );
    }

    #[test]
    fn test_write_and_load_roundtrip() {
        let dir = std::env::temp_dir().join(format!("fg-sup-{}", std::process::id()));
        let path = dir.join(".forge-guard-suppressions");
        std::fs::create_dir_all(&dir).unwrap();
        write(&path, &[finding("FA-H-001-1", "Vault.sol", 42)]).unwrap();
        let loaded = load(&path).unwrap();
        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].id, "FA-H-001");
        let _ = std::fs::remove_dir_all(&dir);
    }
}