use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleId {
NonAsciiHostname,
PunycodeDomain,
MixedScriptInLabel,
UserinfoTrick,
ConfusableDomain,
RawIpUrl,
NonStandardPort,
InvalidHostChars,
TrailingDotWhitespace,
LookalikeTld,
NonAsciiPath,
HomoglyphInPath,
DoubleEncoding,
PlainHttpToSink,
SchemelessToSink,
InsecureTlsFlags,
ShortenedUrl,
AnsiEscapes,
ControlChars,
BidiControls,
ZeroWidthChars,
HiddenMultiline,
UnicodeTags,
InvisibleMathOperator,
VariationSelector,
InvisibleWhitespace,
HangulFiller,
ConfusableText,
PipeToInterpreter,
CurlPipeShell,
WgetPipeShell,
HttpiePipeShell,
XhPipeShell,
DotfileOverwrite,
ArchiveExtract,
ProcMemAccess,
DockerRemotePrivEsc,
CredentialFileSweep,
Base64DecodeExecute,
DataExfiltration,
DynamicCodeExecution,
ObfuscatedPayload,
SuspiciousCodeExfiltration,
ProxyEnvSet,
SensitiveEnvExport,
CodeInjectionEnv,
InterpreterHijackEnv,
ShellInjectionEnv,
MetadataEndpoint,
PrivateNetworkAccess,
CommandNetworkDeny,
ConfigInjection,
ConfigSuspiciousIndicator,
ConfigMalformed,
ConfigNonAscii,
ConfigInvisibleUnicode,
McpInsecureServer,
McpUntrustedServer,
McpDuplicateServerName,
McpOverlyPermissive,
McpSuspiciousArgs,
GitTyposquat,
DockerUntrustedRegistry,
PipUrlInstall,
NpmUrlInstall,
Web3RpcEndpoint,
Web3AddressInUrl,
VetNotConfigured,
ThreatMaliciousPackage,
ThreatMaliciousIp,
ThreatPackageTyposquat,
ThreatPackageSimilarName,
ThreatMaliciousUrl,
ThreatPhishingUrl,
ThreatTorExitNode,
ThreatThreatFoxIoc,
ThreatOsvVulnerable,
ThreatCisaKev,
ThreatSuspiciousPackage,
ThreatSafeBrowsing,
HiddenCssContent,
HiddenColorContent,
HiddenHtmlAttribute,
MarkdownComment,
HtmlComment,
ServerCloaking,
ClipboardHidden,
PdfHiddenText,
CredentialInText,
HighEntropySecret,
PrivateKeyExposed,
PolicyBlocklisted,
CustomRuleMatch,
LicenseRequired,
}
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = serde_json::to_value(self)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| format!("{self:?}"));
write!(f, "{s}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Info => write!(f, "INFO"),
Severity::Low => write!(f, "LOW"),
Severity::Medium => write!(f, "MEDIUM"),
Severity::High => write!(f, "HIGH"),
Severity::Critical => write!(f, "CRITICAL"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Evidence {
Url {
raw: String,
},
HostComparison {
raw_host: String,
similar_to: String,
},
CommandPattern {
pattern: String,
matched: String,
},
ByteSequence {
offset: usize,
hex: String,
description: String,
},
EnvVar {
name: String,
value_preview: String,
},
Text {
detail: String,
},
ThreatIntel {
source: String,
threat_type: String,
confidence: crate::threatdb::Confidence,
#[serde(skip_serializing_if = "Option::is_none")]
reference: Option<String>,
},
HomoglyphAnalysis {
raw: String,
escaped: String,
suspicious_chars: Vec<SuspiciousChar>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuspiciousChar {
pub offset: usize,
#[serde(rename = "character")]
pub character: char,
pub codepoint: String,
pub description: String,
pub hex_bytes: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub rule_id: RuleId,
pub severity: Severity,
pub title: String,
pub description: String,
pub evidence: Vec<Evidence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub human_view: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_view: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mitre_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_rule_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Allow,
Warn,
Block,
WarnAck,
}
impl Action {
pub fn exit_code(self) -> i32 {
match self {
Action::Allow => 0,
Action::Block => 1,
Action::Warn => 2,
Action::WarnAck => 3,
}
}
pub fn rank(self) -> u8 {
match self {
Action::Allow => 0,
Action::Warn | Action::WarnAck => 1,
Action::Block => 2,
}
}
}
pub fn action_from_findings(findings: &[Finding]) -> Action {
if findings.is_empty() {
return Action::Allow;
}
let max_severity = findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Info);
match max_severity {
Severity::Critical | Severity::High => Action::Block,
Severity::Medium | Severity::Low => Action::Warn,
Severity::Info => Action::Allow,
}
}
pub fn upgraded_action_from_findings(findings: &[Finding], current: Action) -> Action {
let derived = action_from_findings(findings);
if derived.rank() > current.rank() {
derived
} else {
current
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Verdict {
pub action: Action,
pub findings: Vec<Finding>,
pub tier_reached: u8,
pub bypass_requested: bool,
pub bypass_honored: bool,
pub bypass_available: bool,
pub interactive_detected: bool,
pub policy_path_used: Option<String>,
pub timings_ms: Timings,
#[serde(skip_serializing_if = "Option::is_none")]
pub urls_extracted_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires_approval: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_fallback: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub escalation_reason: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Timings {
pub tier0_ms: f64,
pub tier1_ms: f64,
pub tier2_ms: Option<f64>,
pub tier3_ms: Option<f64>,
pub total_ms: f64,
}
impl Verdict {
pub fn allow_fast(tier_reached: u8, timings: Timings) -> Self {
Self {
action: Action::Allow,
findings: Vec::new(),
tier_reached,
bypass_requested: false,
bypass_honored: false,
bypass_available: false,
interactive_detected: false,
policy_path_used: None,
timings_ms: timings,
urls_extracted_count: None,
requires_approval: None,
approval_timeout_secs: None,
approval_fallback: None,
approval_rule: None,
approval_description: None,
escalation_reason: None,
}
}
pub fn from_findings(findings: Vec<Finding>, tier_reached: u8, timings: Timings) -> Self {
let action = action_from_findings(&findings);
Self {
action,
findings,
tier_reached,
bypass_requested: false,
bypass_honored: false,
bypass_available: false,
interactive_detected: false,
policy_path_used: None,
timings_ms: timings,
urls_extracted_count: None,
requires_approval: None,
approval_timeout_secs: None,
approval_fallback: None,
approval_rule: None,
approval_description: None,
escalation_reason: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_info_severity_maps_to_allow() {
let findings = vec![Finding {
rule_id: RuleId::NonAsciiHostname, severity: Severity::Info,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
let verdict = Verdict::from_findings(findings, 3, Timings::default());
assert_eq!(verdict.action, Action::Allow);
}
#[test]
fn test_info_severity_display() {
assert_eq!(format!("{}", Severity::Info), "INFO");
}
#[test]
fn test_info_severity_ordering() {
assert!(Severity::Info < Severity::Low);
assert!(Severity::Low < Severity::Medium);
}
#[test]
fn test_upgraded_action_from_findings_upgrades_when_findings_are_stronger() {
let findings = vec![Finding {
rule_id: RuleId::ThreatSuspiciousPackage,
severity: Severity::Medium,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(
upgraded_action_from_findings(&findings, Action::Allow),
Action::Warn
);
}
#[test]
fn test_upgraded_action_from_findings_preserves_stronger_current_action() {
let findings = vec![Finding {
rule_id: RuleId::ThreatSuspiciousPackage,
severity: Severity::Medium,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(
upgraded_action_from_findings(&findings, Action::Block),
Action::Block
);
}
#[test]
fn test_action_from_findings_empty_returns_allow() {
assert_eq!(action_from_findings(&[]), Action::Allow);
}
#[test]
fn test_action_from_findings_high_returns_block() {
let findings = vec![Finding {
rule_id: RuleId::ThreatOsvVulnerable,
severity: Severity::High,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(action_from_findings(&findings), Action::Block);
}
#[test]
fn test_action_from_findings_critical_returns_block() {
let findings = vec![Finding {
rule_id: RuleId::ThreatMaliciousPackage,
severity: Severity::Critical,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(action_from_findings(&findings), Action::Block);
}
#[test]
fn test_action_from_findings_low_returns_warn() {
let findings = vec![Finding {
rule_id: RuleId::ThreatSuspiciousPackage,
severity: Severity::Low,
title: "test".to_string(),
description: "test".to_string(),
evidence: vec![],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}];
assert_eq!(action_from_findings(&findings), Action::Warn);
}
#[test]
fn test_upgraded_action_preserves_current_on_empty_findings() {
assert_eq!(
upgraded_action_from_findings(&[], Action::Block),
Action::Block
);
}
}