T5.8: Tier G response policy — Verdict enum + ResponsePolicy + typed Hangup::PolicyViolation + 9 tests
This commit is contained in:
@@ -1256,7 +1256,7 @@ pub fn default_signal_version() -> u8 {
|
|||||||
1
|
1
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasons for ending a call.
|
/// Typed reason for a call hangup.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum HangupReason {
|
pub enum HangupReason {
|
||||||
Normal,
|
Normal,
|
||||||
@@ -1269,6 +1269,30 @@ pub enum HangupReason {
|
|||||||
/// Versions the server is willing to speak.
|
/// Versions the server is willing to speak.
|
||||||
server_supported: Vec<u8>,
|
server_supported: Vec<u8>,
|
||||||
},
|
},
|
||||||
|
/// Relay conformance policy violation (Tier G).
|
||||||
|
PolicyViolation {
|
||||||
|
/// Machine-readable violation code.
|
||||||
|
code: ViolationCode,
|
||||||
|
/// Human-readable explanation.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine-readable policy-violation codes.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum ViolationCode {
|
||||||
|
/// Tier A — sustained bitrate exceeded codec ceiling.
|
||||||
|
Bitrate,
|
||||||
|
/// Tier B — packet rate exceeded safety limit.
|
||||||
|
PacketRate,
|
||||||
|
/// Tier C — timestamp drift.
|
||||||
|
TimestampDrift,
|
||||||
|
/// Tier D — payload size anomaly.
|
||||||
|
PayloadSize,
|
||||||
|
/// Tier E — per-session rate cap.
|
||||||
|
RateCap,
|
||||||
|
/// Tier F — behavioural entropy score below threshold.
|
||||||
|
Entropy,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod call_registry;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod audio_scorer;
|
pub mod audio_scorer;
|
||||||
pub mod conformance;
|
pub mod conformance;
|
||||||
|
pub mod response_policy;
|
||||||
pub mod event_log;
|
pub mod event_log;
|
||||||
pub mod federation;
|
pub mod federation;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
|
|||||||
226
crates/wzp-relay/src/response_policy.rs
Normal file
226
crates/wzp-relay/src/response_policy.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
//! Tier G response policy — maps conformance verdicts to enforcement actions.
|
||||||
|
//!
|
||||||
|
//! Actions:
|
||||||
|
//! - `Legitimate` → no action
|
||||||
|
//! - `Suspect` → tighten Tier E quota, emit metric
|
||||||
|
//! - `Abusive` → typed Hangup + 1 h fingerprint cool-down
|
||||||
|
//! - `RepeatAbusive` → relay-local block 24 h
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use wzp_proto::packet::{HangupReason, ViolationCode};
|
||||||
|
|
||||||
|
/// Conformance verdict produced by Tier F scoring.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Verdict {
|
||||||
|
/// No suspicion.
|
||||||
|
Legitimate,
|
||||||
|
/// Tightened monitoring.
|
||||||
|
Suspect,
|
||||||
|
/// High confidence of abuse — close session.
|
||||||
|
Abusive,
|
||||||
|
/// Already abusive once in the last 24 h — escalate to block.
|
||||||
|
RepeatAbusive,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforcement action recommended by the response policy.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
/// Pass through unchanged.
|
||||||
|
Allow,
|
||||||
|
/// Throttle to tighter quota (Tier E).
|
||||||
|
Throttle,
|
||||||
|
/// Close the session with a typed Hangup signal.
|
||||||
|
Close { reason: HangupReason },
|
||||||
|
/// Block the fingerprint from joining any room for 24 h.
|
||||||
|
Block,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks fingerprint-level abuse history and applies escalation.
|
||||||
|
pub struct ResponsePolicy {
|
||||||
|
/// `(fingerprint, violation_code)` → last abusive instant.
|
||||||
|
cooldowns: HashMap<(String, ViolationCode), Instant>,
|
||||||
|
/// Cool-down duration for first-time abuse.
|
||||||
|
cooldown_duration: Duration,
|
||||||
|
/// Block duration for repeat abuse.
|
||||||
|
block_duration: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponsePolicy {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
|
cooldown_duration: Duration::from_secs(3600), // 1 h
|
||||||
|
block_duration: Duration::from_secs(86400), // 24 h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate a verdict and produce the corresponding [`Action`].
|
||||||
|
///
|
||||||
|
/// `fingerprint` is the participant's identity string (or IP as fallback).
|
||||||
|
/// `code` is the specific violation type that triggered the verdict.
|
||||||
|
pub fn evaluate(
|
||||||
|
&mut self,
|
||||||
|
fingerprint: &str,
|
||||||
|
code: ViolationCode,
|
||||||
|
verdict: Verdict,
|
||||||
|
) -> Action {
|
||||||
|
match verdict {
|
||||||
|
Verdict::Legitimate => Action::Allow,
|
||||||
|
Verdict::Suspect => Action::Throttle,
|
||||||
|
Verdict::Abusive => {
|
||||||
|
let key = (fingerprint.to_string(), code);
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
// Check if this fingerprint was already abusive recently.
|
||||||
|
let is_repeat = self
|
||||||
|
.cooldowns
|
||||||
|
.get(&key)
|
||||||
|
.map(|last| now.duration_since(*last) < self.block_duration)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if is_repeat {
|
||||||
|
Action::Block
|
||||||
|
} else {
|
||||||
|
self.cooldowns.insert(key, now);
|
||||||
|
Action::Close {
|
||||||
|
reason: HangupReason::PolicyViolation {
|
||||||
|
code,
|
||||||
|
reason: format!("Tier G enforcement: {code:?}"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Verdict::RepeatAbusive => Action::Block,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the fingerprint is currently blocked (repeat abuse).
|
||||||
|
pub fn is_blocked(&self, fingerprint: &str) -> bool {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.cooldowns.iter().any(|((fp, _), last)| {
|
||||||
|
fp == fingerprint && now.duration_since(*last) < self.block_duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up expired cooldown entries.
|
||||||
|
pub fn prune(&mut self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.cooldowns
|
||||||
|
.retain(|_, last| now.duration_since(*last) < self.block_duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of tracked cooldown entries.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.cooldowns.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.cooldowns.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResponsePolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legitimate_allowed() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
assert_eq!(
|
||||||
|
policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Legitimate),
|
||||||
|
Action::Allow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspect_throttled() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
assert_eq!(
|
||||||
|
policy.evaluate("alice", ViolationCode::Entropy, Verdict::Suspect),
|
||||||
|
Action::Throttle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abusive_gets_close() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
let action = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
assert!(
|
||||||
|
matches!(action, Action::Close { .. }),
|
||||||
|
"first-time abuse should close session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeat_abusive_gets_block() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
// First abuse
|
||||||
|
let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
// Second abuse within window → block
|
||||||
|
let action = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
assert_eq!(action, Action::Block, "repeat abuse should block");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_violation_codes_are_independent() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
// Abuse on bitrate
|
||||||
|
let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
// Abuse on entropy is treated as first-time for that code
|
||||||
|
let action = policy.evaluate("alice", ViolationCode::Entropy, Verdict::Abusive);
|
||||||
|
assert!(
|
||||||
|
matches!(action, Action::Close { .. }),
|
||||||
|
"different violation code should not trigger repeat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_blocked_true_after_repeat() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
assert!(policy.is_blocked("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_blocked_false_for_legitimate() {
|
||||||
|
let policy = ResponsePolicy::new();
|
||||||
|
assert!(!policy.is_blocked("alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_removes_expired() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive);
|
||||||
|
assert_eq!(policy.len(), 1);
|
||||||
|
// Manually expire by moving cooldown back
|
||||||
|
policy
|
||||||
|
.cooldowns
|
||||||
|
.insert(("alice".to_string(), ViolationCode::Bitrate), Instant::now() - Duration::from_secs(90000));
|
||||||
|
policy.prune();
|
||||||
|
assert!(policy.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn close_reason_contains_code() {
|
||||||
|
let mut policy = ResponsePolicy::new();
|
||||||
|
let action = policy.evaluate("alice", ViolationCode::Entropy, Verdict::Abusive);
|
||||||
|
match action {
|
||||||
|
Action::Close { reason } => match reason {
|
||||||
|
HangupReason::PolicyViolation { code, .. } => {
|
||||||
|
assert_eq!(code, ViolationCode::Entropy);
|
||||||
|
}
|
||||||
|
other => panic!("expected PolicyViolation, got {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("expected Close, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user