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,
PipeToInterpreter,
CurlPipeShell,
WgetPipeShell,
DotfileOverwrite,
ArchiveExtract,
ProxyEnvSet,
GitTyposquat,
DockerUntrustedRegistry,
PipUrlInstall,
NpmUrlInstall,
Web3RpcEndpoint,
Web3AddressInUrl,
PolicyBlocklisted,
}
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 {
Low,
Medium,
High,
Critical,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
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>,
}
#[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>,
}
#[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,
}
}
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 => Action::Warn,
Severity::Low => Action::Warn,
}
};
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,
}
}
}