use super::condition::Section;
use super::model::{NovaMatch, NovaRule};
use crate::findings::{
ArtifactKind, ArtifactScope, EvidenceKind, Finding, MatchTarget, RecommendedAction, Severity,
SignalClass, ThreatCategory,
};
use std::path::Path;
#[must_use]
pub fn nova_match_to_findings(
rule: &NovaRule,
m: &NovaMatch,
artifact_path: Option<&Path>,
artifact_kind: ArtifactKind,
artifact_scope: ArtifactScope,
) -> Vec<Finding> {
if !m.matched {
return Vec::new();
}
let severity = severity_from_meta(rule.severity_label());
let category = category_from_meta(rule.category_label());
let path_str = artifact_path.map(|p| p.display().to_string());
let ctx = FindingCtx {
rule,
severity,
category,
artifact_kind,
artifact_scope,
path: path_str,
};
let mut out = Vec::new();
for (var, hit) in &m.keyword_hits {
if *hit {
out.push(make_finding(&ctx, Section::Keywords, var, 1.0));
}
}
for (var, hit) in &m.semantic_hits {
if *hit {
let conf = rule.semantics.get(var).map_or(0.5, |p| p.threshold);
out.push(make_finding(&ctx, Section::Semantics, var, conf));
}
}
for (var, hit) in &m.llm_hits {
if *hit {
let conf = rule.llm.get(var).map_or(0.5, |p| p.threshold);
out.push(make_finding(&ctx, Section::Llm, var, conf));
}
}
if out.is_empty() {
out.push(make_finding(&ctx, Section::Keywords, "_condition", 0.7));
}
out
}
struct FindingCtx<'a> {
rule: &'a NovaRule,
severity: Severity,
category: ThreatCategory,
artifact_kind: ArtifactKind,
artifact_scope: ArtifactScope,
path: Option<String>,
}
fn make_finding(ctx: &FindingCtx<'_>, section: Section, var: &str, confidence: f32) -> Finding {
let rule_id = format!("NOVA_{}_{section}_{var}", ctx.rule.name);
let reason = ctx
.rule
.meta
.get("description")
.cloned()
.unwrap_or_else(|| {
format!(
"NOVA rule `{}` matched on `{section}.${var}`",
ctx.rule.name
)
});
let mut builder = Finding::builder(rule_id, ctx.category)
.severity(ctx.severity)
.confidence(confidence)
.reason(reason)
.matched_on(MatchTarget::Document)
.match_value(format!("nova:{section}.${var}"))
.evidence_kind(EvidenceKind::Behavior)
.artifact(ctx.artifact_kind, ctx.path.clone())
.artifact_scope(ctx.artifact_scope)
.signal_class(SignalClass::ReviewSignal)
.action(RecommendedAction::RequireApproval);
if let Some(uuid) = ctx.rule.meta.get("uuid") {
builder = builder.remediation(format!(
"See NOVA rule `{}` (uuid {}). Consult github.com/Nova-Hunting/nova-rules.",
ctx.rule.name, uuid,
));
}
builder.build()
}
fn severity_from_meta(label: Option<&str>) -> Severity {
match label.map(str::to_ascii_lowercase).as_deref() {
Some("critical") => Severity::Critical,
Some("high") => Severity::High,
Some("low") | Some("info") | Some("informational") => Severity::Low,
_ => Severity::Medium,
}
}
fn category_from_meta(label: Option<&str>) -> ThreatCategory {
let Some(raw) = label else {
return ThreatCategory::Generic;
};
let lower = raw.to_ascii_lowercase();
let leaf = lower.rsplit('/').next().unwrap_or(&lower);
match leaf {
"remote_exec" | "remote_execution" => ThreatCategory::RemoteExec,
"supply_chain" => ThreatCategory::SupplyChain,
"credential_exposure" | "credentials" | "secret_disclosure" => {
ThreatCategory::CredentialExposure
}
"tool_abuse" | "tool_misuse" | "agentic_misuse" => ThreatCategory::ToolAbuse,
"autonomy_escalation" => ThreatCategory::AutonomyEscalation,
"privilege_escalation" => ThreatCategory::PrivilegeEscalation,
"data_exfiltration" | "exfiltration" | "exfil" => ThreatCategory::DataExfiltration,
"social_engineering" | "social_manipulation" | "phishing" | "scam" => {
ThreatCategory::SocialManipulation
}
"obfuscation" | "obfuscated" | "evasion" => ThreatCategory::Obfuscation,
"jailbreak"
| "direct_injection"
| "prompt_injection"
| "prompt_manipulation"
| "policy_puppetry" => ThreatCategory::PersistentPromptTampering,
"persuasion" | "persuasive_language" => ThreatCategory::PersuasiveLanguage,
_ => {
let bucket = lower.split('/').next().unwrap_or(&lower);
match bucket {
"abusing_functions" => ThreatCategory::ToolAbuse,
"prompt_manipulation" => ThreatCategory::PersistentPromptTampering,
"supply_chain" => ThreatCategory::SupplyChain,
_ => ThreatCategory::Generic,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nova::condition::ConditionExpr;
use std::collections::BTreeMap;
fn rule(name: &str, severity: &str, category: &str) -> NovaRule {
let mut meta = BTreeMap::new();
meta.insert("description".into(), format!("rule {name} description"));
meta.insert("severity".into(), severity.into());
meta.insert("category".into(), category.into());
meta.insert("uuid".into(), "00000000-0000-0000-0000-000000000001".into());
NovaRule {
name: name.into(),
meta,
keywords: BTreeMap::new(),
semantics: BTreeMap::new(),
llm: BTreeMap::new(),
condition: ConditionExpr::Literal(true),
}
}
fn match_with_keywords(name: &str, hits: &[(&str, bool)]) -> NovaMatch {
NovaMatch {
rule_name: name.into(),
matched: true,
keyword_hits: hits.iter().map(|(k, v)| ((*k).into(), *v)).collect(),
semantic_hits: BTreeMap::new(),
llm_hits: BTreeMap::new(),
skipped_capabilities: vec![],
}
}
#[test]
fn nonmatch_yields_no_findings() {
let r = rule("X", "high", "prompt_manipulation/jailbreak");
let m = NovaMatch {
rule_name: "X".into(),
matched: false,
keyword_hits: BTreeMap::new(),
semantic_hits: BTreeMap::new(),
llm_hits: BTreeMap::new(),
skipped_capabilities: vec![],
};
let findings = nova_match_to_findings(
&r,
&m,
None,
ArtifactKind::SkillDocument,
ArtifactScope::AgentEntrypoint,
);
assert!(findings.is_empty());
}
#[test]
fn each_matching_keyword_gets_its_own_finding() {
let r = rule("Multi", "high", "abusing_functions/agentic_misuse");
let m = match_with_keywords("Multi", &[("a", true), ("b", true), ("c", false)]);
let findings = nova_match_to_findings(
&r,
&m,
None,
ArtifactKind::SkillDocument,
ArtifactScope::AgentEntrypoint,
);
assert_eq!(findings.len(), 2);
let ids: Vec<&str> = findings.iter().map(|f| f.rule_id.as_str()).collect();
assert!(ids.contains(&"NOVA_Multi_keywords_a"));
assert!(ids.contains(&"NOVA_Multi_keywords_b"));
assert!(!ids.contains(&"NOVA_Multi_keywords_c"));
}
#[test]
fn severity_mapping_matches_nova_labels() {
assert_eq!(severity_from_meta(Some("critical")), Severity::Critical);
assert_eq!(severity_from_meta(Some("HIGH")), Severity::High);
assert_eq!(severity_from_meta(Some("medium")), Severity::Medium);
assert_eq!(severity_from_meta(Some("low")), Severity::Low);
assert_eq!(severity_from_meta(Some("info")), Severity::Low);
assert_eq!(severity_from_meta(None), Severity::Medium);
assert_eq!(severity_from_meta(Some("nonsense")), Severity::Medium);
}
#[test]
fn category_mapping_for_real_nova_buckets() {
assert_eq!(
category_from_meta(Some("prompt_manipulation/jailbreak")),
ThreatCategory::PersistentPromptTampering
);
assert_eq!(
category_from_meta(Some("abusing_functions/agentic_misuse")),
ThreatCategory::ToolAbuse
);
assert_eq!(
category_from_meta(Some("abusing_functions/social_engineering")),
ThreatCategory::SocialManipulation
);
assert_eq!(
category_from_meta(Some("supply_chain/dependency")),
ThreatCategory::SupplyChain
);
assert_eq!(
category_from_meta(Some("totally_unknown")),
ThreatCategory::Generic
);
assert_eq!(category_from_meta(None), ThreatCategory::Generic);
}
#[test]
fn nova_findings_are_review_signal_require_approval() {
let r = rule("Test", "critical", "prompt_manipulation/jailbreak");
let m = match_with_keywords("Test", &[("a", true)]);
let f = &nova_match_to_findings(
&r,
&m,
None,
ArtifactKind::SkillDocument,
ArtifactScope::AgentEntrypoint,
)[0];
assert_eq!(f.signal_class, SignalClass::ReviewSignal);
assert_eq!(f.recommended_action, RecommendedAction::RequireApproval);
assert_eq!(f.severity, Severity::Critical); }
#[test]
fn artifact_metadata_flows_from_caller() {
let r = rule("X", "medium", "tool_abuse");
let m = match_with_keywords("X", &[("a", true)]);
let path = Path::new("/tmp/foo/SKILL.md");
let f = &nova_match_to_findings(
&r,
&m,
Some(path),
ArtifactKind::SkillDocument,
ArtifactScope::SupportingArtifact,
)[0];
assert_eq!(f.artifact_path.as_deref(), Some("/tmp/foo/SKILL.md"));
assert_eq!(f.artifact_scope, ArtifactScope::SupportingArtifact);
}
}