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,
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,
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,
},
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,
}
impl Action {
pub fn exit_code(self) -> i32 {
match self {
Action::Allow => 0,
Action::Block => 1,
Action::Warn => 2,
}
}
}
#[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 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>,
}
#[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,
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,
}
}
pub fn from_findings(findings: Vec<Finding>, tier_reached: u8, timings: Timings) -> Self {
let action = if findings.is_empty() {
Action::Allow
} else {
let max_severity = findings
.iter()
.map(|f| f.severity)
.max()
.unwrap_or(Severity::Low);
match max_severity {
Severity::Critical | Severity::High => Action::Block,
Severity::Medium | Severity::Low => Action::Warn,
Severity::Info => Action::Allow,
}
};
Self {
action,
findings,
tier_reached,
bypass_requested: false,
bypass_honored: 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,
}
}
}
#[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);
}
}