Files
intercept/utils/isms/rules.py
Smittix 35d138175e Add ISMS Listening Station with GSM cell detection
- Add spectrum monitoring via rtl_power with configurable presets
- Add OpenCelliD tower integration with Leaflet map display
- Add grgsm_scanner integration for passive GSM cell detection (alpha)
- Add rules engine for anomaly detection and findings
- Add baseline recording and comparison system
- Add setup.sh support for gr-gsm installation on Debian/Ubuntu

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 11:12:09 +00:00

402 lines
14 KiB
Python

"""
Rules engine for ISMS anomaly detection.
Provides a configurable rules system for detecting spectrum anomalies,
cellular environment changes, and suspicious RF patterns.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
logger = logging.getLogger('intercept.isms.rules')
@dataclass
class Finding:
"""A detected anomaly or observation."""
finding_type: str
severity: str # 'info', 'warn', 'high'
description: str
band: str | None = None
frequency: float | None = None
details: dict = field(default_factory=dict)
timestamp: datetime = field(default_factory=datetime.now)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
'finding_type': self.finding_type,
'severity': self.severity,
'description': self.description,
'band': self.band,
'frequency': self.frequency,
'details': self.details,
'timestamp': self.timestamp.isoformat(),
}
@dataclass
class Rule:
"""An anomaly detection rule."""
name: str
description: str
severity: str # 'info', 'warn', 'high'
check: Callable[[dict], bool]
message_template: str
category: str = 'general'
enabled: bool = True
def evaluate(self, context: dict) -> Finding | None:
"""
Evaluate rule against context.
Args:
context: Dictionary with detection context data
Returns:
Finding if rule triggered, None otherwise
"""
if not self.enabled:
return None
try:
if self.check(context):
# Format message with context values
try:
message = self.message_template.format(**context)
except KeyError:
message = self.message_template
return Finding(
finding_type=self.name,
severity=self.severity,
description=message,
band=context.get('band'),
frequency=context.get('frequency') or context.get('freq_mhz'),
details=context,
)
except Exception as e:
logger.debug(f"Rule {self.name} evaluation error: {e}")
return None
# Built-in anomaly detection rules
ISMS_RULES: list[Rule] = [
Rule(
name='burst_detected',
description='Short RF burst above noise floor',
severity='warn',
category='spectrum',
check=lambda ctx: ctx.get('burst_count', 0) > 0,
message_template='Detected {burst_count} burst(s) in {band}',
),
Rule(
name='periodic_burst',
description='Repeated periodic bursts consistent with beacon',
severity='warn',
category='spectrum',
check=lambda ctx: (
ctx.get('burst_count', 0) >= 3 and
ctx.get('burst_interval_stdev', float('inf')) < 2.0
),
message_template='Periodic bursts detected (~{burst_interval_avg:.1f}s interval) in {band}',
),
Rule(
name='new_peak_frequency',
description='New peak frequency not in baseline',
severity='info',
category='spectrum',
check=lambda ctx: ctx.get('is_new_peak', False),
message_template='New peak at {freq_mhz:.3f} MHz ({power_db:.1f} dB)',
),
Rule(
name='strong_signal_indoors',
description='Strong signal above indoor threshold',
severity='warn',
category='spectrum',
check=lambda ctx: ctx.get('power_db', -100) > ctx.get('indoor_threshold', -40),
message_template='Strong signal ({power_db:.1f} dB) at {freq_mhz:.3f} MHz',
),
Rule(
name='noise_floor_increase',
description='Significant noise floor increase from baseline',
severity='warn',
category='spectrum',
check=lambda ctx: ctx.get('noise_delta', 0) > 6, # >6dB increase
message_template='Noise floor increased by {noise_delta:.1f} dB in {band}',
),
Rule(
name='noise_floor_decrease',
description='Significant noise floor decrease from baseline',
severity='info',
category='spectrum',
check=lambda ctx: ctx.get('noise_delta', 0) < -6, # >6dB decrease
message_template='Noise floor decreased by {noise_delta:.1f} dB in {band}',
),
Rule(
name='high_activity_band',
description='Unusually high activity in band',
severity='info',
category='spectrum',
check=lambda ctx: ctx.get('activity_score', 0) > 80,
message_template='High activity ({activity_score:.0f}%) in {band}',
),
Rule(
name='activity_increase',
description='Activity score increased from baseline',
severity='info',
category='spectrum',
check=lambda ctx: ctx.get('activity_delta', 0) > 30, # >30% increase
message_template='Activity increased by {activity_delta:.0f}% in {band}',
),
Rule(
name='new_cell_detected',
description='New cell tower not in baseline',
severity='info',
category='cellular',
check=lambda ctx: ctx.get('is_new_cell', False),
message_template='New cell: {plmn} {radio} CID {cell_id}',
),
Rule(
name='cell_disappeared',
description='Previously seen cell no longer detected',
severity='info',
category='cellular',
check=lambda ctx: ctx.get('is_missing_cell', False),
message_template='Cell no longer seen: {plmn} CID {cell_id}',
),
Rule(
name='new_operator',
description='New network operator detected',
severity='warn',
category='cellular',
check=lambda ctx: ctx.get('is_new_operator', False),
message_template='New operator detected: {operator} ({plmn})',
),
Rule(
name='signal_strength_change',
description='Significant change in cell signal strength',
severity='info',
category='cellular',
check=lambda ctx: abs(ctx.get('rsrp_delta', 0)) > 10, # >10dB change
message_template='Signal change: {plmn} CID {cell_id} ({rsrp_delta:+.0f} dB)',
),
Rule(
name='suspicious_ism_activity',
description='Unusual activity in ISM band',
severity='warn',
category='spectrum',
check=lambda ctx: (
ctx.get('band', '').startswith('ISM') and
ctx.get('activity_score', 0) > 60 and
ctx.get('is_new_peak', False)
),
message_template='Suspicious ISM activity at {freq_mhz:.3f} MHz',
),
]
class RulesEngine:
"""Engine for evaluating ISMS detection rules."""
def __init__(self, rules: list[Rule] | None = None):
"""
Initialize rules engine.
Args:
rules: List of rules to use (defaults to ISMS_RULES)
"""
self.rules = rules if rules is not None else ISMS_RULES.copy()
self._custom_rules: list[Rule] = []
def add_rule(self, rule: Rule) -> None:
"""Add a custom rule."""
self._custom_rules.append(rule)
def remove_rule(self, rule_name: str) -> bool:
"""Remove a rule by name."""
for rules_list in [self.rules, self._custom_rules]:
for i, rule in enumerate(rules_list):
if rule.name == rule_name:
rules_list.pop(i)
return True
return False
def enable_rule(self, rule_name: str) -> bool:
"""Enable a rule by name."""
for rule in self.rules + self._custom_rules:
if rule.name == rule_name:
rule.enabled = True
return True
return False
def disable_rule(self, rule_name: str) -> bool:
"""Disable a rule by name."""
for rule in self.rules + self._custom_rules:
if rule.name == rule_name:
rule.enabled = False
return True
return False
def get_rules_by_category(self, category: str) -> list[Rule]:
"""Get all rules in a category."""
return [
r for r in self.rules + self._custom_rules
if r.category == category and r.enabled
]
def evaluate(self, context: dict) -> list[Finding]:
"""
Evaluate all rules against context.
Args:
context: Dictionary with detection context data
Returns:
List of Finding objects for triggered rules
"""
findings = []
for rule in self.rules + self._custom_rules:
finding = rule.evaluate(context)
if finding:
findings.append(finding)
logger.debug(f"Rule '{rule.name}' triggered: {finding.description}")
return findings
def evaluate_spectrum(
self,
band_name: str,
noise_floor: float,
peak_freq: float,
peak_power: float,
activity_score: float,
baseline_noise: float | None = None,
baseline_activity: float | None = None,
baseline_peaks: list[float] | None = None,
burst_count: int = 0,
burst_interval_avg: float | None = None,
burst_interval_stdev: float | None = None,
indoor_threshold: float = -40,
) -> list[Finding]:
"""
Evaluate spectrum-related rules.
Args:
band_name: Name of the band
noise_floor: Current noise floor in dB
peak_freq: Peak frequency in MHz
peak_power: Peak power in dB
activity_score: Activity score 0-100
baseline_noise: Baseline noise floor for comparison
baseline_activity: Baseline activity score for comparison
baseline_peaks: List of baseline peak frequencies
burst_count: Number of bursts detected
burst_interval_avg: Average interval between bursts
burst_interval_stdev: Standard deviation of burst intervals
indoor_threshold: Power threshold for indoor signal detection
Returns:
List of Finding objects
"""
context = {
'band': band_name,
'freq_mhz': peak_freq,
'power_db': peak_power,
'noise_floor': noise_floor,
'activity_score': activity_score,
'burst_count': burst_count,
'indoor_threshold': indoor_threshold,
}
# Calculate deltas from baseline
if baseline_noise is not None:
context['noise_delta'] = noise_floor - baseline_noise
if baseline_activity is not None:
context['activity_delta'] = activity_score - baseline_activity
# Check if peak is new
if baseline_peaks is not None:
# Consider peak "new" if not within 0.1 MHz of any baseline peak
context['is_new_peak'] = all(
abs(peak_freq - bp) > 0.1 for bp in baseline_peaks
)
else:
context['is_new_peak'] = False
# Add burst timing info
if burst_interval_avg is not None:
context['burst_interval_avg'] = burst_interval_avg
if burst_interval_stdev is not None:
context['burst_interval_stdev'] = burst_interval_stdev
return self.evaluate(context)
def evaluate_cellular(
self,
plmn: str,
cell_id: int,
radio: str,
rsrp: int | None = None,
operator: str | None = None,
baseline_cells: list[dict] | None = None,
baseline_operators: list[str] | None = None,
previous_rsrp: int | None = None,
) -> list[Finding]:
"""
Evaluate cellular-related rules.
Args:
plmn: PLMN code (MCC-MNC)
cell_id: Cell ID
radio: Radio type (GSM, UMTS, LTE, NR)
rsrp: Signal strength in dBm
operator: Operator name
baseline_cells: List of baseline cell dicts for comparison
baseline_operators: List of baseline operator PLMNs
previous_rsrp: Previous RSRP reading for this cell
Returns:
List of Finding objects
"""
context = {
'plmn': plmn,
'cell_id': cell_id,
'radio': radio,
'operator': operator or plmn,
}
if rsrp is not None:
context['rsrp'] = rsrp
# Check if cell is new
if baseline_cells is not None:
context['is_new_cell'] = not any(
c.get('cell_id') == cell_id and c.get('plmn') == plmn
for c in baseline_cells
)
else:
context['is_new_cell'] = False
# Check if operator is new
if baseline_operators is not None:
context['is_new_operator'] = plmn not in baseline_operators
# Calculate RSRP delta
if rsrp is not None and previous_rsrp is not None:
context['rsrp_delta'] = rsrp - previous_rsrp
return self.evaluate(context)
def create_default_engine() -> RulesEngine:
"""Create a rules engine with default rules."""
return RulesEngine(ISMS_RULES.copy())