Expose severity to display

See https://github.com/EFForg/rayhunter/issues/334

Severity levels low, medium, high are now exposed to the UI in form of
dotted, dashed and solid lines. The line on the UI represents the
highest-so-far severity seen.

Originally this was intended to be represented by Yellow/Orange/Red, but
this would mean yet another divergence for colorblind mode. This is
colorblind-friendly by default (I think...)

As part of this, simplify EventType so that it becomes a flat "level"
enum without nested variants.

There is also a new debug endpoint that allows one to overwrite the
display level directly for testing.
This commit is contained in:
Markus Unterwaditzer
2025-08-03 21:01:24 +02:00
committed by Cooper Quintin
parent 6927da49b4
commit 781d11ed72
24 changed files with 443 additions and 292 deletions

View File

@@ -43,31 +43,63 @@ impl Default for AnalyzerConfig {
pub const REPORT_VERSION: u32 = 2;
/// Qualitative measure of how severe a Warning event type is.
/// The levels should break down like this:
/// * Low: if combined with a large number of other Warnings, user should investigate
/// * Medium: if combined with a few other Warnings, user should investigate
/// * High: user should investigate
#[derive(Serialize, Debug, Clone)]
pub enum Severity {
Low,
Medium,
High,
/// The severity level of an event.
///
/// Informational does not result in any alert on the display.
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum EventType {
Informational = 0,
Low = 1,
Medium = 2,
High = 3,
}
/// `QualitativeWarning` events will always be shown to the user in some manner,
/// while `Informational` ones may be hidden based on user settings.
#[derive(Serialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum EventType {
Informational,
QualitativeWarning { severity: Severity },
impl<'de> Deserialize<'de> for EventType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
#[serde(tag = "type")]
enum OldEventType {
QualitativeWarning { severity: String },
Informational,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum EventTypeHelper {
New(String),
Old(OldEventType),
}
match EventTypeHelper::deserialize(deserializer)? {
EventTypeHelper::New(s) => match s.as_str() {
"Informational" => Ok(EventType::Informational),
"Low" => Ok(EventType::Low),
"Medium" => Ok(EventType::Medium),
"High" => Ok(EventType::High),
_ => Err(D::Error::custom(format!("unknown EventType: {s}"))),
},
EventTypeHelper::Old(old) => match old {
OldEventType::Informational => Ok(EventType::Informational),
OldEventType::QualitativeWarning { severity } => match severity.as_str() {
"Low" => Ok(EventType::Low),
"Medium" => Ok(EventType::Medium),
"High" => Ok(EventType::High),
_ => Err(D::Error::custom(format!("unknown severity: {severity}"))),
},
},
}
}
}
/// Events are user-facing signals that can be emitted by an [Analyzer] upon a
/// message being received. They can be used to signifiy an IC detection
/// warning, or just to display some relevant information to the user.
#[derive(Serialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Event {
pub event_type: EventType,
pub message: String,
@@ -100,21 +132,77 @@ pub trait Analyzer {
fn get_version(&self) -> u32;
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct AnalyzerMetadata {
pub name: String,
pub description: String,
pub version: u32,
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(default)]
#[derive(Default)]
pub struct ReportMetadata {
pub analyzers: Vec<AnalyzerMetadata>,
pub rayhunter: RuntimeMetadata,
// anytime the format of the report changes, bump this by 1
//
// the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us
// clearly differentiate some known false-positive-results from the pre-versioned era from v1
// heuristics
pub report_version: u32,
}
impl ReportMetadata {
/// Normalize the report metadata to the current version
pub fn normalize(&mut self) {
self.report_version = REPORT_VERSION;
}
}
/// Normalizer for analysis report lines that maintains state internally.
/// The first line is expected to be ReportMetadata, and subsequent lines
/// are expected to be AnalysisRow entries.
pub struct AnalysisLineNormalizer {
is_first: bool,
}
impl Default for AnalysisLineNormalizer {
fn default() -> Self {
Self::new()
}
}
impl AnalysisLineNormalizer {
pub fn new() -> Self {
Self { is_first: true }
}
/// Normalize a single line from an analysis report.
/// Returns the normalized JSON string with a newline appended.
pub fn normalize_line(&mut self, line: String) -> String {
if self.is_first {
self.is_first = false;
// the first line is the report metadata. we overwrite the report version there to
// latest, because the output of the remaining lines will follow latest versions
if let Ok(mut metadata) = serde_json::from_str::<ReportMetadata>(&line) {
metadata.normalize();
serde_json::to_string(&metadata).unwrap_or(line) + "\n"
} else {
line + "\n"
}
} else {
// Remaining lines are AnalysisRow, roundtrip them through serde to normalize them.
if let Ok(row) = serde_json::from_str::<AnalysisRow>(&line) {
serde_json::to_string(&row).unwrap_or(line) + "\n"
} else {
line + "\n"
}
}
}
}
#[derive(Serialize, Debug)]
pub struct AnalysisRow {
pub packet_timestamp: Option<DateTime<FixedOffset>>,
@@ -128,12 +216,81 @@ impl AnalysisRow {
}
pub fn contains_warnings(&self) -> bool {
for event in self.events.iter().flatten() {
if matches!(event.event_type, EventType::QualitativeWarning { .. }) {
return true;
}
self.get_max_event_type() != EventType::Informational
}
pub fn get_max_event_type(&self) -> EventType {
self.events
.iter()
.flatten()
.map(|event| event.event_type)
.max()
.unwrap_or(EventType::Informational)
}
}
impl<'de> Deserialize<'de> for AnalysisRow {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
struct V1AnalysisEntry {
timestamp: DateTime<FixedOffset>,
events: Vec<Option<Event>>,
}
#[derive(Deserialize)]
struct V1Format {
timestamp: DateTime<FixedOffset>,
skipped_message_reasons: Vec<String>,
analysis: Vec<V1AnalysisEntry>,
}
#[derive(Deserialize)]
struct V2Format {
packet_timestamp: Option<DateTime<FixedOffset>>,
skipped_message_reason: Option<String>,
events: Vec<Option<Event>>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RowFormat {
V1(V1Format),
V2(V2Format),
}
match RowFormat::deserialize(deserializer)? {
RowFormat::V1(v1) => {
// For v1 format, we can only deserialize the first non-skipped analysis entry
// The caller needs to handle multiple rows differently for v1
if let Some(first_analysis) = v1.analysis.first() {
Ok(AnalysisRow {
packet_timestamp: Some(first_analysis.timestamp),
skipped_message_reason: None,
events: first_analysis.events.clone(),
})
} else if let Some(first_reason) = v1.skipped_message_reasons.first() {
Ok(AnalysisRow {
packet_timestamp: Some(v1.timestamp),
skipped_message_reason: Some(first_reason.clone()),
events: Vec::new(),
})
} else {
Err(D::Error::custom(
"V1 format has no analysis entries or skipped reasons",
))
}
}
RowFormat::V2(v2) => Ok(AnalysisRow {
packet_timestamp: v2.packet_timestamp,
skipped_message_reason: v2.skipped_message_reason,
events: v2.events,
}),
}
false
}
}
@@ -293,3 +450,57 @@ impl Harness {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_analysis_row_deserialize_old_format() {
let row: AnalysisRow = serde_json::from_value(json!({
"packet_timestamp": "2023-01-01T00:00:00+00:00",
"skipped_message_reason": null,
"events": [
{
"event_type": { "type": "QualitativeWarning", "severity": "High" },
"message": "Test warning"
},
{
"event_type": { "type": "Informational" },
"message": "Test info"
},
null
]
}))
.unwrap();
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
assert_eq!(
row.events[1].as_ref().unwrap().event_type,
EventType::Informational
);
assert!(row.events[2].is_none());
}
#[test]
fn test_analysis_row_deserialize_new_format() {
let row: AnalysisRow = serde_json::from_value(json!({
"packet_timestamp": "2023-01-01T00:00:00+00:00",
"skipped_message_reason": null,
"events": [
{ "event_type": "High", "message": "Test warning" },
{ "event_type": "Informational", "message": "Test info" },
null
]
}))
.unwrap();
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
assert_eq!(
row.events[1].as_ref().unwrap().event_type,
EventType::Informational
);
assert!(row.events[2].is_none());
}
}

View File

@@ -1,6 +1,6 @@
use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
use telcom_parser::lte_rrc::{
DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions,
@@ -36,9 +36,7 @@ impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
{
match carrier_info {
RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: "Detected 2G downgrade".to_owned(),
}),
_ => Some(Event {

View File

@@ -3,7 +3,7 @@ use std::borrow::Cow;
use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
use log::debug;
@@ -59,9 +59,7 @@ impl ImsiRequestedAnalyzer {
// Unexpected IMSI without AttachRequest
(current, State::IdentityRequest) if *current != State::AttachRequest => {
self.flag = Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: format!(
"Identity requested without Attach Request (frame {})",
self.packet_num
@@ -73,9 +71,7 @@ impl ImsiRequestedAnalyzer {
// IMSI to Disconnect without AuthAccept
(State::IdentityRequest, State::Disconnect) => {
self.flag = Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: format!(
"Disconnected after Identity Request without Auth Accept (frame {})",
self.packet_num

View File

@@ -2,7 +2,7 @@ use std::borrow::Cow;
use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1};
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
pub struct IncompleteSibAnalyzer {
@@ -44,9 +44,7 @@ impl Analyzer for IncompleteSibAnalyzer {
&& sib1.scheduling_info_list.0.len() < 2
{
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::Medium,
},
event_type: EventType::Medium,
message: format!(
"SIB1 scheduling info list was malformed (packet {})",
self.packet_num

View File

@@ -4,7 +4,7 @@ use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage;
use pycrate_rs::nas::generated::emm::emm_security_mode_command::NASSecAlgoCiphAlgo::EPSEncryptionAlgorithmEEA0Null;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
pub struct NasNullCipherAnalyzer {
@@ -52,9 +52,7 @@ impl Analyzer for NasNullCipherAnalyzer {
&& req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null
{
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: format!(
"NAS Security mode command requested null cipher(packet {})",
self.packet_num

View File

@@ -8,7 +8,7 @@ use telcom_parser::lte_rrc::{
SecurityModeCommandCriticalExtensions, SecurityModeCommandCriticalExtensions_c1,
};
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
pub struct NullCipherAnalyzer {}
@@ -153,9 +153,7 @@ impl Analyzer for NullCipherAnalyzer {
};
if null_cipher_detected {
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: "Cell suggested use of null cipher".to_string(),
});
}

View File

@@ -1,6 +1,6 @@
use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
use telcom_parser::lte_rrc::{
BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1, CellReselectionPriority,
@@ -61,9 +61,7 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
&& p == 0
{
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message:
"LTE cell advertised a 3G cell for priority 0 reselection"
.to_string(),
@@ -78,9 +76,7 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
&& p == 0
{
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message:
"LTE cell advertised a 3G cell for priority 0 reselection"
.to_string(),
@@ -101,9 +97,7 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
&& p == 0
{
return Some(Event {
event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
event_type: EventType::High,
message: "LTE cell advertised a 2G cell for priority 0 reselection"
.to_string(),
});

View File

@@ -1,10 +1,10 @@
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[cfg(target_family = "unix")]
use nix::sys::utsname::uname;
/// Expose binary and system information.
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
pub struct RuntimeMetadata {
/// The cargo package version from this library's cargo.toml, e.g., "1.2.3".
pub rayhunter_version: String,