use std::path::Path;
use std::sync::LazyLock;
use crate::analyzer::SkillDocument;
use crate::findings::{
ArtifactKind, EvidenceKind, Finding, MatchTarget, RecommendedAction, Severity, SignalClass,
ThreatCategory,
};
use crate::ports::CompiledPattern;
pub(crate) struct CompositeSignal {
pub(crate) label: &'static str,
pub(crate) pattern: &'static LazyLock<CompiledPattern>,
}
pub(crate) struct CompositeFamily {
pub(crate) rule_id: &'static str,
pub(crate) category: ThreatCategory,
pub(crate) severity: Severity,
pub(crate) action: RecommendedAction,
pub(crate) signal_class: SignalClass,
pub(crate) min_signals: usize,
pub(crate) signals: &'static [CompositeSignal],
pub(crate) match_value_prefix: &'static str,
pub(crate) reason: &'static str,
}
impl CompositeFamily {
pub(crate) fn evaluate(
&self,
path: &Path,
doc: &SkillDocument,
artifact_kind: ArtifactKind,
) -> Vec<Finding> {
let text = doc.raw_content.as_str();
let present: Vec<&str> = self
.signals
.iter()
.filter(|s| s.pattern.is_match(text))
.map(|s| s.label)
.collect();
if present.len() < self.min_signals {
return Vec::new();
}
vec![Finding::builder(self.rule_id, self.category)
.severity(self.severity)
.action(self.action)
.evidence_kind(EvidenceKind::Behavior)
.signal_class(self.signal_class)
.matched_on(MatchTarget::Document)
.artifact(artifact_kind, Some(path.display().to_string()))
.match_value(format!(
"{}{}",
self.match_value_prefix,
present.join(" + ")
))
.reason(self.reason)
.build()]
}
}
pub(crate) const COMPOSITE_FAMILY_COUNT: usize = 3;
static REGISTRY: [&CompositeFamily; COMPOSITE_FAMILY_COUNT] = [
&super::dropper_delivery::FAKE_DEPENDENCY_DROPPER,
&super::composite_families::CRYPTO_WALLET_DRAINER_DROPPER,
&super::composite_families::C2_BEACON_DROPPER,
];
pub(crate) fn composite_families() -> &'static [&'static CompositeFamily] {
®ISTRY
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn composite_families_have_unique_rule_ids() {
let mut ids: Vec<&str> = composite_families().iter().map(|f| f.rule_id).collect();
ids.sort_unstable();
let before = ids.len();
ids.dedup();
assert_eq!(before, ids.len(), "duplicate composite rule_id");
}
#[test]
fn every_family_min_signals_between_two_and_n() {
for f in composite_families() {
assert!(
f.min_signals >= 2,
"{}: a 1-of-n composite is forbidden",
f.rule_id
);
assert!(
f.min_signals <= f.signals.len(),
"{}: min_signals exceeds signal count",
f.rule_id
);
}
}
#[test]
fn composite_family_count_is_pinned() {
assert_eq!(composite_families().len(), COMPOSITE_FAMILY_COUNT);
}
}