gossan-correlation 0.3.0

Cross-module finding correlation engine for gossan (attack chain detection) — part of the security research ecosystem
Documentation
//! Correlates multiple TLS/crypto weaknesses on the same host into a chain finding.
//! Self-signed cert + expired cert + missing HSTS = "defence in depth completely absent."

use gossan_core::Target;
use secfinding::{Finding, FindingKind, Severity};

use crate::utils::normalize_host;
use crate::CorrelationRule;
/// TlsWeaknessRule correlation rule — detects multi-signal attack chains.
pub struct TlsWeaknessRule;

impl CorrelationRule for TlsWeaknessRule {
    fn name(&self) -> &'static str {
        "tls-weakness-chain"
    }

    fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
        // Group TLS/header findings by host, tracking both normalized and original targets
        let mut host_issues: std::collections::HashMap<String, (Vec<String>, String)> =
            std::collections::HashMap::new();

        for f in findings {
            let title_lc = f.title().to_lowercase();
            let is_tls = title_lc.contains("self-signed")
                || title_lc.contains("expired")
                || title_lc.contains("hsts")
                || title_lc.contains("certificate")
                || title_lc.contains("tls");
            if !is_tls {
                continue;
            }
            let Some(host) = Some(f.target()) else {
                continue;
            };
            let normalized_host = normalize_host(host);
            let entry = host_issues
                .entry(normalized_host)
                .or_insert((Vec::new(), host.to_string()));
            entry.0.push(f.title().to_string());
        }

        host_issues
            .into_iter()
            .filter_map(|(normalized_host, (issues, original_target))| {
                // Deduplicate issue titles — same finding fires on multiple targets (http/https/ports)
                let mut seen = std::collections::HashSet::new();
                let unique: Vec<String> = issues
                    .into_iter()
                    .filter(|t| seen.insert(t.clone()))
                    .collect();
                if unique.len() < 2 {
                    return None;
                }
                // Use normalized host if original target had ports/schemes, otherwise use original
                let target_for_chain =
                    if original_target.contains(':') || original_target.starts_with("http") {
                        normalized_host.clone()
                    } else {
                        original_target.clone()
                    };
                Finding::builder("correlation", target_for_chain, Severity::High)
                    .title(format!("Multiple TLS weaknesses on {}", normalized_host))
                    .detail(format!(
                        "{} has {} distinct TLS/transport-security issues: {}. \
                         Combined, these indicate complete absence of transport security hygiene \
                         and make MitM attacks highly feasible.",
                        normalized_host,
                        unique.len(),
                        unique.join("; ")
                    ))
                    .kind(FindingKind::Vulnerability)
                    .tag("chain")
                    .tag("tls")
                    .tag("transport")
                    .build_or_log()
            })
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    fn finding(target: &str, title: &str) -> Finding {
        Finding::builder("portscan", target, Severity::High)
            .title(title)
            .build()
            .expect("test finding")
    }

    #[test]
    fn tls_weakness_rule_requires_multiple_distinct_issues() {
        let findings = vec![finding("example.com", "TLS certificate expired")];
        assert!(TlsWeaknessRule.check(&findings, &[]).is_empty());
    }

    #[test]
    fn tls_weakness_rule_deduplicates_repeated_titles() {
        let findings = vec![
            finding("example.com", "TLS certificate expired"),
            finding("example.com", "TLS certificate expired"),
            finding("example.com", "Self-signed TLS certificate"),
        ];
        let chains = TlsWeaknessRule.check(&findings, &[]);
        assert_eq!(chains.len(), 1);
        assert!(chains[0]
            .detail()
            .contains("2 distinct TLS/transport-security issues"));
    }
}