use crate::security_config::SecurityTier;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SignalId(std::borrow::Cow<'static, str>);
impl SignalId {
#[must_use]
pub const fn new(id: &'static str) -> Self {
Self(std::borrow::Cow::Borrowed(id))
}
#[must_use]
pub fn from_runtime_string(id: &str) -> Self {
Self(std::borrow::Cow::Owned(id.to_string()))
}
#[must_use]
pub fn scoped(prefix: &'static str, scope: &str) -> Self {
let mut sanitized = String::with_capacity(scope.len());
for ch in scope.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
sanitized.push(ch.to_ascii_lowercase());
} else {
sanitized.push('_');
}
}
Self(std::borrow::Cow::Owned(format!("{prefix}.{sanitized}")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SignalId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Category {
InputInjection,
ScreenRecording,
GuiPresence,
EnvironmentInjection,
BinaryIntegrity,
DiskPermissions,
DeviceMismatch,
AuditFailure,
SandboxUnavailable,
AccessibilityBridge,
BinaryRisk,
IoContext,
SecretQuality,
SupervisorLeak,
Other,
}
impl Category {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::InputInjection => "input_injection",
Self::ScreenRecording => "screen_recording",
Self::GuiPresence => "gui_presence",
Self::EnvironmentInjection => "environment_injection",
Self::BinaryIntegrity => "binary_integrity",
Self::DiskPermissions => "disk_permissions",
Self::DeviceMismatch => "device_mismatch",
Self::AuditFailure => "audit_failure",
Self::SandboxUnavailable => "sandbox_unavailable",
Self::AccessibilityBridge => "accessibility_bridge",
Self::BinaryRisk => "binary_risk",
Self::IoContext => "io_context",
Self::SecretQuality => "secret_quality",
Self::SupervisorLeak => "supervisor_leak",
Self::Other => "other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Severity {
Info,
Warn,
Degraded,
Hostile,
Critical,
}
impl Severity {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Info => "info",
Self::Warn => "warn",
Self::Degraded => "degraded",
Self::Hostile => "hostile",
Self::Critical => "critical",
}
}
}
#[derive(Debug, Clone)]
pub struct Signal {
pub id: SignalId,
pub category: Category,
pub severity: Severity,
pub label: &'static str,
pub detail: String,
pub mitigation: &'static str,
}
impl Signal {
#[must_use]
pub fn new(
id: SignalId,
category: Category,
severity: Severity,
label: &'static str,
detail: impl Into<String>,
mitigation: &'static str,
) -> Self {
Self {
id,
category,
severity,
label,
detail: detail.into(),
mitigation,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Ignore,
Log,
Warn {
friction: bool,
},
Block,
}
impl Action {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Ignore => "ignore",
Self::Log => "log",
Self::Warn { friction: false } => "warn",
Self::Warn { friction: true } => "warn_with_challenge",
Self::Block => "block",
}
}
#[must_use]
pub const fn blocks(&self) -> bool {
matches!(self, Self::Block)
}
}
#[derive(Debug, Clone, Default)]
pub struct Policy {
overrides: std::collections::HashMap<(Severity, SecurityTier), Action>,
per_signal: std::collections::HashMap<SignalId, Action>,
}
impl Policy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn override_action(&mut self, severity: Severity, tier: SecurityTier, action: Action) {
self.overrides.insert((severity, tier), action);
}
pub fn override_signal(&mut self, id: SignalId, action: Action) {
self.per_signal.insert(id, action);
}
#[must_use]
pub fn decide(&self, signal: &Signal, tier: SecurityTier) -> Action {
let action = if let Some(action) = self.per_signal.get(&signal.id).copied() {
action
} else if let Some(action) = self.overrides.get(&(signal.severity, tier)).copied() {
action
} else {
Self::default_action(signal.severity, tier)
};
if signal.severity == Severity::Critical && action == Action::Ignore {
Action::Log
} else {
action
}
}
#[must_use]
#[allow(clippy::match_same_arms)] pub const fn default_action(severity: Severity, tier: SecurityTier) -> Action {
match (severity, tier) {
(Severity::Info, _) => Action::Log,
(Severity::Warn, SecurityTier::Standard | SecurityTier::Hardened) => {
Action::Warn { friction: false }
}
(Severity::Warn, SecurityTier::Lockdown) => Action::Warn { friction: true },
(Severity::Degraded, SecurityTier::Standard) => Action::Warn { friction: true },
(Severity::Degraded, SecurityTier::Hardened | SecurityTier::Lockdown) => Action::Block,
(Severity::Hostile, SecurityTier::Standard) => Action::Warn { friction: true },
(Severity::Hostile, SecurityTier::Hardened | SecurityTier::Lockdown) => Action::Block,
(Severity::Critical, _) => Action::Block,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Decision {
pub blocked: bool,
pub needs_friction: bool,
pub warnings: Vec<String>,
pub blocking_signal: Option<Signal>,
pub log_entries: Vec<Signal>,
}
#[must_use]
pub fn evaluate(signals: &[Signal], policy: &Policy, tier: SecurityTier) -> Decision {
let mut decision = Decision::default();
for signal in signals {
match policy.decide(signal, tier) {
Action::Ignore => {}
Action::Log => decision.log_entries.push(signal.clone()),
Action::Warn { friction } => {
decision.warnings.push(format!(
"{}: {} — {}",
signal.label, signal.detail, signal.mitigation
));
if friction {
decision.needs_friction = true;
}
decision.log_entries.push(signal.clone());
}
Action::Block => {
decision.blocked = true;
if decision.blocking_signal.is_none() {
decision.blocking_signal = Some(signal.clone());
}
decision.log_entries.push(signal.clone());
}
}
}
decision
}
#[cfg(test)]
mod tests {
use super::*;
fn sig(severity: Severity) -> Signal {
Signal::new(
SignalId::new("test.signal.x"),
Category::Other,
severity,
"test signal",
"detail goes here",
"fix it",
)
}
#[test]
fn default_policy_info_is_logged_at_every_tier() {
for tier in [
SecurityTier::Standard,
SecurityTier::Hardened,
SecurityTier::Lockdown,
] {
assert_eq!(Policy::default_action(Severity::Info, tier), Action::Log);
}
}
#[test]
fn default_policy_critical_always_blocks() {
for tier in [
SecurityTier::Standard,
SecurityTier::Hardened,
SecurityTier::Lockdown,
] {
assert_eq!(
Policy::default_action(Severity::Critical, tier),
Action::Block
);
}
}
#[test]
fn default_policy_hostile_blocks_at_hardened_and_above() {
assert_eq!(
Policy::default_action(Severity::Hostile, SecurityTier::Hardened),
Action::Block
);
assert_eq!(
Policy::default_action(Severity::Hostile, SecurityTier::Lockdown),
Action::Block
);
match Policy::default_action(Severity::Hostile, SecurityTier::Standard) {
Action::Warn { friction: true } => {}
other => panic!("expected warn-with-friction, got {other:?}"),
}
}
#[test]
fn policy_per_signal_override_beats_severity_table() {
let mut policy = Policy::new();
let id = SignalId::new("specific.signal");
policy.override_signal(id.clone(), Action::Ignore);
let s = Signal::new(
id.clone(),
Category::Other,
Severity::Hostile,
"would-be-blocking signal",
"",
"",
);
assert_eq!(policy.decide(&s, SecurityTier::Lockdown), Action::Ignore);
let crit = Signal::new(
id,
Category::Other,
Severity::Critical,
"would-be-blocking signal",
"",
"",
);
assert_eq!(policy.decide(&crit, SecurityTier::Lockdown), Action::Log);
}
#[test]
fn policy_severity_tier_override_works() {
let mut policy = Policy::new();
policy.override_action(Severity::Warn, SecurityTier::Standard, Action::Ignore);
let s = sig(Severity::Warn);
assert_eq!(policy.decide(&s, SecurityTier::Standard), Action::Ignore);
assert_eq!(
policy.decide(&s, SecurityTier::Hardened),
Action::Warn { friction: false }
);
}
#[test]
fn evaluate_aggregates_warnings_and_blocking() {
let signals = [
sig(Severity::Warn),
sig(Severity::Hostile),
sig(Severity::Info),
];
let policy = Policy::new();
let d = evaluate(&signals, &policy, SecurityTier::Lockdown);
assert!(d.blocked, "Hostile under Lockdown must block");
assert!(!d.warnings.is_empty(), "Warn must produce a user warning");
assert_eq!(d.log_entries.len(), 3, "all 3 signals should be logged");
assert!(d.blocking_signal.is_some());
}
#[test]
fn scoped_signal_id_sanitizes_unsafe_chars() {
let id = SignalId::scoped("gui.input_injector", "xdotool");
assert_eq!(id.as_str(), "gui.input_injector.xdotool");
let id = SignalId::scoped("env.preload", "LD_PRELOAD.evil.so");
assert_eq!(id.as_str(), "env.preload.ld_preload_evil_so");
let id = SignalId::scoped("gui.screen_recorder", "OBS Studio");
assert_eq!(id.as_str(), "gui.screen_recorder.obs_studio");
}
#[test]
fn scoped_signal_id_is_lowercased() {
let id = SignalId::scoped("env.preload", "LD_PRELOAD");
assert_eq!(id.as_str(), "env.preload.ld_preload");
}
#[test]
fn per_signal_override_can_target_a_scoped_id() {
let mut policy = Policy::new();
let xdotool_id = SignalId::scoped("gui.input_injector", "xdotool");
let ydotool_id = SignalId::scoped("gui.input_injector", "ydotool");
policy.override_signal(xdotool_id.clone(), Action::Ignore);
let xdotool_signal = Signal::new(
xdotool_id,
Category::InputInjection,
Severity::Hostile,
"xdotool",
"running",
"stop it",
);
let ydotool_signal = Signal::new(
ydotool_id,
Category::InputInjection,
Severity::Hostile,
"ydotool",
"running",
"stop it",
);
assert_eq!(
policy.decide(&xdotool_signal, SecurityTier::Lockdown),
Action::Ignore
);
assert_eq!(
policy.decide(&ydotool_signal, SecurityTier::Lockdown),
Action::Block
);
}
#[test]
fn category_str_is_stable() {
assert_eq!(Category::InputInjection.as_str(), "input_injection");
assert_eq!(Category::ScreenRecording.as_str(), "screen_recording");
assert_eq!(Category::DeviceMismatch.as_str(), "device_mismatch");
}
#[test]
fn severity_ordering() {
assert!(Severity::Info < Severity::Warn);
assert!(Severity::Warn < Severity::Degraded);
assert!(Severity::Degraded < Severity::Hostile);
assert!(Severity::Hostile < Severity::Critical);
}
#[test]
fn action_blocks_helper() {
assert!(Action::Block.blocks());
assert!(!Action::Warn { friction: true }.blocks());
assert!(!Action::Log.blocks());
assert!(!Action::Ignore.blocks());
}
#[test]
fn empty_signals_yield_empty_decision() {
let policy = Policy::new();
let d = evaluate(&[], &policy, SecurityTier::Lockdown);
assert!(!d.blocked);
assert!(!d.needs_friction);
assert!(d.warnings.is_empty());
assert!(d.blocking_signal.is_none());
assert!(d.log_entries.is_empty());
}
}