forensicnomicon 0.3.1

The ForensicNomicon — comprehensive DFIR artifact catalog: UserAssist, Shimcache, Amcache, Prefetch, $MFT, ShellBags, EVTX, NTDS.dit, SAM, SRUM, LNK, Jump Lists + KAPE/Velociraptor/Sigma/MITRE. Zero deps.
Documentation
use super::profile::{Classification, FiredSignal, MalwareProfile, ProfileMatch};
use std::collections::HashSet;

#[derive(Debug)]
pub struct DetectedSignal {
    pub id: &'static str,
    pub confidence: f32,
    pub evidence: String,
}

pub fn score_against_profile(
    signals: &[DetectedSignal],
    profile: &'static MalwareProfile,
) -> ProfileMatch {
    let present: HashSet<&str> = signals.iter().map(|s| s.id).collect();

    let missed_required: Vec<&'static str> = profile
        .signals
        .iter()
        .filter(|ps| ps.required && !present.contains(ps.id))
        .map(|ps| ps.id)
        .collect();

    if !missed_required.is_empty() {
        return ProfileMatch {
            profile,
            score: 0,
            classification: Classification::NoMatch,
            fired: vec![],
            missed_required,
        };
    }

    let mut fired = Vec::new();
    let raw_score: u32 = profile
        .signals
        .iter()
        .filter(|ps| present.contains(ps.id))
        .map(|ps| {
            fired.push(FiredSignal {
                id: ps.id,
                weight: ps.weight,
            });
            ps.weight
        })
        .sum();

    let penalty: u32 = profile
        .exclusions
        .iter()
        .filter(|ex| present.contains(ex.id))
        .map(|ex| ex.penalty)
        .sum();

    let score = raw_score.saturating_sub(penalty);

    let classification = if score == 0 {
        Classification::LowConfidence
    } else if score >= profile.confirmed_threshold {
        Classification::Confirmed
    } else if score >= profile.probable_threshold {
        Classification::Probable
    } else if score >= profile.class_threshold {
        Classification::ClassMatch
    } else {
        Classification::LowConfidence
    };

    ProfileMatch {
        profile,
        score,
        classification,
        fired,
        missed_required: vec![],
    }
}

pub fn score_all_profiles(signals: &[DetectedSignal]) -> Vec<ProfileMatch> {
    use super::profiles::ALL_PROFILES;
    let mut matches: Vec<ProfileMatch> = ALL_PROFILES
        .iter()
        .map(|p| score_against_profile(signals, p))
        .filter(|m| m.score > 0)
        .collect();
    matches.sort_by(|a, b| b.score.cmp(&a.score).then(a.profile.id.cmp(b.profile.id)));
    matches
}

pub fn top_match(signals: &[DetectedSignal]) -> Option<ProfileMatch> {
    score_all_profiles(signals)
        .into_iter()
        .find(|m| m.classification >= Classification::ClassMatch)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::threat_intel::{profile::Classification, signals::*};

    fn sig(id: &'static str) -> DetectedSignal {
        DetectedSignal {
            id,
            confidence: 1.0,
            evidence: String::new(),
        }
    }

    fn sigs(ids: &[&'static str]) -> Vec<DetectedSignal> {
        ids.iter().map(|&id| sig(id)).collect()
    }

    #[test]
    fn score_zero_signals_returns_no_match_for_all_profiles() {
        let matches = score_all_profiles(&[]);
        assert!(matches.is_empty(), "no signals → no profiles should match");
    }

    #[test]
    fn score_father_required_signals_returns_father_class_match() {
        use crate::threat_intel::profiles::FATHER;
        let signals = sigs(&[ELF_HOOKS_PROCESS_HIDING, ELF_HOOKS_PAM_CREDENTIAL]);
        let m = score_against_profile(&signals, &FATHER);
        assert!(m.score >= FATHER.class_threshold);
        assert!(m.classification >= Classification::ClassMatch);
    }

    #[test]
    fn score_father_full_signals_returns_father_confirmed() {
        use crate::threat_intel::profiles::FATHER;
        let signals = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
            ARTIFACT_PAM_STAGING_STRUCTURAL,
            ARTIFACT_PAM_STAGING_FATHER,
            ELF_STRING_FATHER_FORMAT,
            ELF_STRING_STAGING_PATH,
            ELF_GLOBALLY_LOADED,
            ELF_NOT_IN_PKG_DB,
            TEMPORAL_LDPRELOAD_SSHD_RESTART,
        ]);
        let m = score_against_profile(&signals, &FATHER);
        assert_eq!(m.classification, Classification::Confirmed);
    }

    #[test]
    fn score_jynx_with_pam_signal_excluded_by_penalty() {
        use crate::threat_intel::profiles::JYNX;
        let signals = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_FILE_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
        ]);
        let m = score_against_profile(&signals, &JYNX);
        assert!(
            m.classification < Classification::ClassMatch,
            "PAM signal should drop Jynx below class threshold, got {:?} (score {})",
            m.classification,
            m.score
        );
    }

    #[test]
    fn score_bdvl_required_signals_returns_bdvl_class_match() {
        use crate::threat_intel::profiles::BDVL;
        let signals = sigs(&[ELF_HOOKS_PROCESS_HIDING, ELF_HOOKS_NETWORK_HIDING]);
        let m = score_against_profile(&signals, &BDVL);
        assert!(m.score >= BDVL.class_threshold);
        assert!(m.classification >= Classification::ClassMatch);
    }

    #[test]
    fn score_xmrig_thread_signal_returns_xmrig_class_match() {
        use crate::threat_intel::profiles::XMRIG;
        let signals = sigs(&[PROCESS_THREAD_MINER_XMRIG]);
        let m = score_against_profile(&signals, &XMRIG);
        assert!(m.score >= XMRIG.class_threshold);
        assert!(m.classification >= Classification::ClassMatch);
    }

    #[test]
    fn score_all_profiles_sorted_descending_by_score() {
        let signals = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
            ARTIFACT_PAM_STAGING_FATHER,
            ELF_STRING_FATHER_FORMAT,
        ]);
        let matches = score_all_profiles(&signals);
        assert!(!matches.is_empty());
        for w in matches.windows(2) {
            assert!(
                w[0].score >= w[1].score,
                "results not sorted: {} < {}",
                w[0].score,
                w[1].score
            );
        }
    }

    #[test]
    fn score_all_profiles_filters_zero_score() {
        let signals = sigs(&[ELF_HOOKS_PROCESS_HIDING, ELF_HOOKS_PAM_CREDENTIAL]);
        let matches = score_all_profiles(&signals);
        assert!(matches.iter().all(|m| m.score > 0));
    }

    #[test]
    fn top_match_returns_highest_classification() {
        let signals = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
            ARTIFACT_PAM_STAGING_STRUCTURAL,
            ARTIFACT_PAM_STAGING_FATHER,
            ELF_STRING_FATHER_FORMAT,
        ]);
        let top = top_match(&signals);
        assert!(top.is_some());
        let top = top.unwrap();
        assert!(top.classification >= Classification::ClassMatch);
    }

    #[test]
    fn score_missing_required_signal_is_no_match() {
        use crate::threat_intel::profiles::FATHER;
        let signals = sigs(&[ELF_HOOKS_PROCESS_HIDING]);
        let m = score_against_profile(&signals, &FATHER);
        assert_eq!(m.classification, Classification::NoMatch);
        assert_eq!(m.score, 0);
        assert!(!m.missed_required.is_empty());
    }

    #[test]
    fn score_exclusion_reduces_score() {
        use crate::threat_intel::profiles::FATHER;
        let without_exclusion = sigs(&[ELF_HOOKS_PROCESS_HIDING, ELF_HOOKS_PAM_CREDENTIAL]);
        let with_exclusion = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
            ELF_HOOKS_NETWORK_HIDING,
        ]);
        let m_no_ex = score_against_profile(&without_exclusion, &FATHER);
        let m_ex = score_against_profile(&with_exclusion, &FATHER);
        assert!(m_ex.score < m_no_ex.score, "exclusion should lower score");
    }

    #[test]
    fn score_exclusion_cannot_exceed_raw_score() {
        use crate::threat_intel::profiles::JYNX;
        let signals = sigs(&[
            ELF_HOOKS_PROCESS_HIDING,
            ELF_HOOKS_FILE_HIDING,
            ELF_HOOKS_PAM_CREDENTIAL,
        ]);
        let m = score_against_profile(&signals, &JYNX);
        let _ = m.score;
    }
}