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>
This commit is contained in:
Smittix
2026-01-16 11:12:09 +00:00
parent 4c1690dd28
commit 35d138175e
15 changed files with 5578 additions and 4 deletions

View File

@@ -194,6 +194,76 @@ def init_db() -> None:
ON tscm_sweeps(baseline_id)
''')
# =====================================================================
# ISMS (Intelligent Spectrum Monitoring Station) Tables
# =====================================================================
# ISMS Baselines - Location-based spectrum profiles
conn.execute('''
CREATE TABLE IF NOT EXISTS isms_baselines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
location_name TEXT,
latitude REAL,
longitude REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
spectrum_profile TEXT,
cellular_environment TEXT,
known_towers TEXT,
is_active BOOLEAN DEFAULT 0
)
''')
# ISMS Scans - Individual scan sessions
conn.execute('''
CREATE TABLE IF NOT EXISTS isms_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
baseline_id INTEGER,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
status TEXT DEFAULT 'running',
scan_preset TEXT,
gps_coords TEXT,
results TEXT,
findings_count INTEGER DEFAULT 0,
FOREIGN KEY (baseline_id) REFERENCES isms_baselines(id)
)
''')
# ISMS Findings - Detected anomalies and observations
conn.execute('''
CREATE TABLE IF NOT EXISTS isms_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER,
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finding_type TEXT NOT NULL,
severity TEXT DEFAULT 'info',
band TEXT,
frequency REAL,
description TEXT,
details TEXT,
acknowledged BOOLEAN DEFAULT 0,
FOREIGN KEY (scan_id) REFERENCES isms_scans(id)
)
''')
# ISMS indexes for performance
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_isms_baselines_location
ON isms_baselines(latitude, longitude)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_isms_findings_severity
ON isms_findings(severity, detected_at)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_isms_scans_baseline
ON isms_scans(baseline_id)
''')
logger.info("Database initialized successfully")
@@ -793,3 +863,354 @@ def get_tscm_threat_summary() -> dict:
summary['total'] += row['count']
return summary
# =============================================================================
# ISMS Functions
# =============================================================================
def create_isms_baseline(
name: str,
location_name: str | None = None,
latitude: float | None = None,
longitude: float | None = None,
spectrum_profile: dict | None = None,
cellular_environment: list | None = None,
known_towers: list | None = None
) -> int:
"""
Create a new ISMS baseline.
Returns:
The ID of the created baseline
"""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO isms_baselines
(name, location_name, latitude, longitude, spectrum_profile,
cellular_environment, known_towers)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
name,
location_name,
latitude,
longitude,
json.dumps(spectrum_profile) if spectrum_profile else None,
json.dumps(cellular_environment) if cellular_environment else None,
json.dumps(known_towers) if known_towers else None
))
return cursor.lastrowid
def get_isms_baseline(baseline_id: int) -> dict | None:
"""Get a specific ISMS baseline by ID."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM isms_baselines WHERE id = ?',
(baseline_id,)
)
row = cursor.fetchone()
if row is None:
return None
return {
'id': row['id'],
'name': row['name'],
'location_name': row['location_name'],
'latitude': row['latitude'],
'longitude': row['longitude'],
'created_at': row['created_at'],
'updated_at': row['updated_at'],
'spectrum_profile': json.loads(row['spectrum_profile']) if row['spectrum_profile'] else {},
'cellular_environment': json.loads(row['cellular_environment']) if row['cellular_environment'] else [],
'known_towers': json.loads(row['known_towers']) if row['known_towers'] else [],
'is_active': bool(row['is_active'])
}
def get_all_isms_baselines() -> list[dict]:
"""Get all ISMS baselines."""
with get_db() as conn:
cursor = conn.execute('''
SELECT id, name, location_name, latitude, longitude, created_at, is_active
FROM isms_baselines
ORDER BY created_at DESC
''')
return [dict(row) for row in cursor]
def get_active_isms_baseline() -> dict | None:
"""Get the currently active ISMS baseline."""
with get_db() as conn:
cursor = conn.execute(
'SELECT * FROM isms_baselines WHERE is_active = 1 LIMIT 1'
)
row = cursor.fetchone()
if row is None:
return None
return get_isms_baseline(row['id'])
def set_active_isms_baseline(baseline_id: int | None) -> bool:
"""Set a baseline as active (deactivates others). Pass None to deactivate all."""
with get_db() as conn:
# Deactivate all
conn.execute('UPDATE isms_baselines SET is_active = 0')
if baseline_id is not None:
# Activate selected
cursor = conn.execute(
'UPDATE isms_baselines SET is_active = 1 WHERE id = ?',
(baseline_id,)
)
return cursor.rowcount > 0
return True
def update_isms_baseline(
baseline_id: int,
spectrum_profile: dict | None = None,
cellular_environment: list | None = None,
known_towers: list | None = None
) -> bool:
"""Update baseline spectrum/cellular data."""
updates = ['updated_at = CURRENT_TIMESTAMP']
params = []
if spectrum_profile is not None:
updates.append('spectrum_profile = ?')
params.append(json.dumps(spectrum_profile))
if cellular_environment is not None:
updates.append('cellular_environment = ?')
params.append(json.dumps(cellular_environment))
if known_towers is not None:
updates.append('known_towers = ?')
params.append(json.dumps(known_towers))
params.append(baseline_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE isms_baselines SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def delete_isms_baseline(baseline_id: int) -> bool:
"""Delete an ISMS baseline."""
with get_db() as conn:
cursor = conn.execute(
'DELETE FROM isms_baselines WHERE id = ?',
(baseline_id,)
)
return cursor.rowcount > 0
def create_isms_scan(
scan_preset: str,
baseline_id: int | None = None,
gps_coords: dict | None = None
) -> int:
"""
Create a new ISMS scan session.
Returns:
The ID of the created scan
"""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO isms_scans (baseline_id, scan_preset, gps_coords)
VALUES (?, ?, ?)
''', (
baseline_id,
scan_preset,
json.dumps(gps_coords) if gps_coords else None
))
return cursor.lastrowid
def update_isms_scan(
scan_id: int,
status: str | None = None,
results: dict | None = None,
findings_count: int | None = None,
completed: bool = False
) -> bool:
"""Update an ISMS scan."""
updates = []
params = []
if status is not None:
updates.append('status = ?')
params.append(status)
if results is not None:
updates.append('results = ?')
params.append(json.dumps(results))
if findings_count is not None:
updates.append('findings_count = ?')
params.append(findings_count)
if completed:
updates.append('completed_at = CURRENT_TIMESTAMP')
if not updates:
return False
params.append(scan_id)
with get_db() as conn:
cursor = conn.execute(
f'UPDATE isms_scans SET {", ".join(updates)} WHERE id = ?',
params
)
return cursor.rowcount > 0
def get_isms_scan(scan_id: int) -> dict | None:
"""Get a specific ISMS scan by ID."""
with get_db() as conn:
cursor = conn.execute('SELECT * FROM isms_scans WHERE id = ?', (scan_id,))
row = cursor.fetchone()
if row is None:
return None
return {
'id': row['id'],
'baseline_id': row['baseline_id'],
'started_at': row['started_at'],
'completed_at': row['completed_at'],
'status': row['status'],
'scan_preset': row['scan_preset'],
'gps_coords': json.loads(row['gps_coords']) if row['gps_coords'] else None,
'results': json.loads(row['results']) if row['results'] else None,
'findings_count': row['findings_count']
}
def get_recent_isms_scans(limit: int = 20) -> list[dict]:
"""Get recent ISMS scans."""
with get_db() as conn:
cursor = conn.execute('''
SELECT id, baseline_id, started_at, completed_at, status,
scan_preset, findings_count
FROM isms_scans
ORDER BY started_at DESC
LIMIT ?
''', (limit,))
return [dict(row) for row in cursor]
def add_isms_finding(
scan_id: int,
finding_type: str,
severity: str,
description: str,
band: str | None = None,
frequency: float | None = None,
details: dict | None = None
) -> int:
"""
Add a finding to an ISMS scan.
Returns:
The ID of the created finding
"""
with get_db() as conn:
cursor = conn.execute('''
INSERT INTO isms_findings
(scan_id, finding_type, severity, band, frequency, description, details)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
scan_id, finding_type, severity, band, frequency, description,
json.dumps(details) if details else None
))
# Increment findings count on scan
conn.execute(
'UPDATE isms_scans SET findings_count = findings_count + 1 WHERE id = ?',
(scan_id,)
)
return cursor.lastrowid
def get_isms_findings(
scan_id: int | None = None,
severity: str | None = None,
acknowledged: bool | None = None,
limit: int = 100
) -> list[dict]:
"""Get ISMS findings with optional filters."""
conditions = []
params = []
if scan_id is not None:
conditions.append('scan_id = ?')
params.append(scan_id)
if severity is not None:
conditions.append('severity = ?')
params.append(severity)
if acknowledged is not None:
conditions.append('acknowledged = ?')
params.append(1 if acknowledged else 0)
where_clause = f'WHERE {" AND ".join(conditions)}' if conditions else ''
params.append(limit)
with get_db() as conn:
cursor = conn.execute(f'''
SELECT * FROM isms_findings
{where_clause}
ORDER BY detected_at DESC
LIMIT ?
''', params)
results = []
for row in cursor:
results.append({
'id': row['id'],
'scan_id': row['scan_id'],
'detected_at': row['detected_at'],
'finding_type': row['finding_type'],
'severity': row['severity'],
'band': row['band'],
'frequency': row['frequency'],
'description': row['description'],
'details': json.loads(row['details']) if row['details'] else None,
'acknowledged': bool(row['acknowledged'])
})
return results
def acknowledge_isms_finding(finding_id: int) -> bool:
"""Acknowledge an ISMS finding."""
with get_db() as conn:
cursor = conn.execute(
'UPDATE isms_findings SET acknowledged = 1 WHERE id = ?',
(finding_id,)
)
return cursor.rowcount > 0
def get_isms_findings_summary() -> dict:
"""Get summary counts of findings by severity."""
with get_db() as conn:
cursor = conn.execute('''
SELECT severity, COUNT(*) as count
FROM isms_findings
WHERE acknowledged = 0
GROUP BY severity
''')
summary = {'high': 0, 'warn': 0, 'info': 0, 'total': 0}
for row in cursor:
summary[row['severity']] = row['count']
summary['total'] += row['count']
return summary

79
utils/isms/__init__.py Normal file
View File

@@ -0,0 +1,79 @@
"""
ISMS (Intelligent Spectrum Monitoring Station) utilities.
Provides spectrum analysis, tower integration, anomaly detection,
and baseline management for RF situational awareness.
"""
from .spectrum import (
SpectrumBin,
BandMetrics,
run_rtl_power_scan,
compute_band_metrics,
detect_bursts,
get_rtl_power_path,
)
from .towers import (
CellTower,
query_nearby_towers,
build_cellmapper_url,
build_ofcom_coverage_url,
build_ofcom_emf_url,
get_opencellid_token,
)
from .rules import (
Rule,
Finding,
RulesEngine,
ISMS_RULES,
)
from .baseline import (
BaselineRecorder,
compare_spectrum_baseline,
compare_tower_baseline,
)
from .gsm import (
GsmCell,
GsmScanResult,
run_grgsm_scan,
run_gsm_scan_blocking,
get_grgsm_scanner_path,
format_gsm_cell,
deduplicate_cells,
identify_gsm_anomalies,
)
__all__ = [
# Spectrum
'SpectrumBin',
'BandMetrics',
'run_rtl_power_scan',
'compute_band_metrics',
'detect_bursts',
'get_rtl_power_path',
# Towers
'CellTower',
'query_nearby_towers',
'build_cellmapper_url',
'build_ofcom_coverage_url',
'build_ofcom_emf_url',
'get_opencellid_token',
# Rules
'Rule',
'Finding',
'RulesEngine',
'ISMS_RULES',
# Baseline
'BaselineRecorder',
'compare_spectrum_baseline',
'compare_tower_baseline',
# GSM
'GsmCell',
'GsmScanResult',
'run_grgsm_scan',
'run_gsm_scan_blocking',
'get_grgsm_scanner_path',
'format_gsm_cell',
'deduplicate_cells',
'identify_gsm_anomalies',
]

533
utils/isms/baseline.py Normal file
View File

@@ -0,0 +1,533 @@
"""
ISMS baseline management.
Provides functions for recording, storing, and comparing
spectrum and cellular baselines.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from statistics import mean, stdev
from typing import Any
from utils.database import (
create_isms_baseline,
get_isms_baseline,
update_isms_baseline,
get_active_isms_baseline,
)
from .spectrum import BandMetrics, SpectrumBin, compute_band_metrics
from .towers import CellTower
logger = logging.getLogger('intercept.isms.baseline')
@dataclass
class SpectrumBaseline:
"""Baseline spectrum profile for a band."""
band_name: str
freq_start_mhz: float
freq_end_mhz: float
noise_floor_db: float
avg_power_db: float
activity_score: float
peak_frequencies: list[float] # MHz
recorded_at: datetime = field(default_factory=datetime.now)
sample_count: int = 0
def to_dict(self) -> dict:
"""Convert to dictionary for storage."""
return {
'band_name': self.band_name,
'freq_start_mhz': self.freq_start_mhz,
'freq_end_mhz': self.freq_end_mhz,
'noise_floor_db': self.noise_floor_db,
'avg_power_db': self.avg_power_db,
'activity_score': self.activity_score,
'peak_frequencies': self.peak_frequencies,
'recorded_at': self.recorded_at.isoformat(),
'sample_count': self.sample_count,
}
@classmethod
def from_dict(cls, data: dict) -> 'SpectrumBaseline':
"""Create from dictionary."""
recorded_at = data.get('recorded_at')
if isinstance(recorded_at, str):
recorded_at = datetime.fromisoformat(recorded_at)
elif recorded_at is None:
recorded_at = datetime.now()
return cls(
band_name=data.get('band_name', 'Unknown'),
freq_start_mhz=data.get('freq_start_mhz', 0),
freq_end_mhz=data.get('freq_end_mhz', 0),
noise_floor_db=data.get('noise_floor_db', -100),
avg_power_db=data.get('avg_power_db', -100),
activity_score=data.get('activity_score', 0),
peak_frequencies=data.get('peak_frequencies', []),
recorded_at=recorded_at,
sample_count=data.get('sample_count', 0),
)
@dataclass
class CellularBaseline:
"""Baseline cellular environment."""
cells: list[dict] # List of cell info dicts
operators: list[str] # List of PLMNs seen
recorded_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> dict:
"""Convert to dictionary for storage."""
return {
'cells': self.cells,
'operators': self.operators,
'recorded_at': self.recorded_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> 'CellularBaseline':
"""Create from dictionary."""
recorded_at = data.get('recorded_at')
if isinstance(recorded_at, str):
recorded_at = datetime.fromisoformat(recorded_at)
elif recorded_at is None:
recorded_at = datetime.now()
return cls(
cells=data.get('cells', []),
operators=data.get('operators', []),
recorded_at=recorded_at,
)
@dataclass
class TowerBaseline:
"""Baseline tower environment."""
towers: list[dict] # List of tower info dicts
recorded_at: datetime = field(default_factory=datetime.now)
def to_dict(self) -> dict:
"""Convert to dictionary for storage."""
return {
'towers': self.towers,
'recorded_at': self.recorded_at.isoformat(),
}
@classmethod
def from_dict(cls, data: dict) -> 'TowerBaseline':
"""Create from dictionary."""
recorded_at = data.get('recorded_at')
if isinstance(recorded_at, str):
recorded_at = datetime.fromisoformat(recorded_at)
elif recorded_at is None:
recorded_at = datetime.now()
return cls(
towers=data.get('towers', []),
recorded_at=recorded_at,
)
class BaselineRecorder:
"""Records spectrum and cellular data for baseline creation."""
def __init__(self):
self._spectrum_samples: dict[str, list[BandMetrics]] = {}
self._cellular_samples: list[dict] = []
self._tower_samples: list[dict] = []
self._gsm_cells: list[dict] = []
self._recording = False
self._started_at: datetime | None = None
@property
def is_recording(self) -> bool:
"""Check if recording is active."""
return self._recording
def start_recording(self) -> None:
"""Start baseline recording."""
self._spectrum_samples.clear()
self._cellular_samples.clear()
self._tower_samples.clear()
self._gsm_cells.clear()
self._recording = True
self._started_at = datetime.now()
logger.info("Started baseline recording")
def stop_recording(self) -> dict:
"""
Stop recording and return compiled baseline data.
Returns:
Dictionary with spectrum_profile, cellular_environment, known_towers
"""
self._recording = False
# Compile spectrum baselines
spectrum_profile = {}
for band_name, samples in self._spectrum_samples.items():
if samples:
spectrum_profile[band_name] = self._compile_spectrum_baseline(
band_name, samples
).to_dict()
# Compile cellular baseline
cellular_env = self._compile_cellular_baseline().to_dict()
# Compile tower baseline
tower_data = self._compile_tower_baseline().to_dict()
logger.info(
f"Stopped baseline recording: {len(spectrum_profile)} bands, "
f"{len(cellular_env.get('cells', []))} cells, "
f"{len(tower_data.get('towers', []))} towers, "
f"{len(self._gsm_cells)} GSM cells"
)
return {
'spectrum_profile': spectrum_profile,
'cellular_environment': cellular_env.get('cells', []),
'known_towers': tower_data.get('towers', []),
'gsm_cells': self._gsm_cells.copy(),
}
def add_spectrum_sample(self, band_name: str, metrics: BandMetrics) -> None:
"""Add a spectrum sample during recording."""
if not self._recording:
return
if band_name not in self._spectrum_samples:
self._spectrum_samples[band_name] = []
self._spectrum_samples[band_name].append(metrics)
def add_cellular_sample(self, cell_info: dict) -> None:
"""Add a cellular sample during recording."""
if not self._recording:
return
# Deduplicate by cell_id + plmn
key = (cell_info.get('cell_id'), cell_info.get('plmn'))
existing = next(
(c for c in self._cellular_samples
if (c.get('cell_id'), c.get('plmn')) == key),
None
)
if existing:
# Update signal strength if stronger
if cell_info.get('rsrp', -200) > existing.get('rsrp', -200):
existing.update(cell_info)
else:
self._cellular_samples.append(cell_info)
def add_tower_sample(self, tower: CellTower | dict) -> None:
"""Add a tower sample during recording."""
if not self._recording:
return
if isinstance(tower, CellTower):
tower_dict = tower.to_dict()
else:
tower_dict = tower
# Deduplicate by cellid
cell_id = tower_dict.get('cellid')
if not any(t.get('cellid') == cell_id for t in self._tower_samples):
self._tower_samples.append(tower_dict)
def add_gsm_cell(self, cell: Any) -> None:
"""
Add a GSM cell sample during recording.
Args:
cell: GsmCell object or dict with GSM cell info
"""
if not self._recording:
return
# Convert to dict if needed
if hasattr(cell, 'arfcn'):
# It's a GsmCell object
cell_dict = {
'arfcn': cell.arfcn,
'freq_mhz': cell.freq_mhz,
'power_dbm': cell.power_dbm,
'mcc': cell.mcc,
'mnc': cell.mnc,
'lac': cell.lac,
'cell_id': cell.cell_id,
'bsic': cell.bsic,
'plmn': cell.plmn,
'cell_global_id': cell.cell_global_id,
}
else:
cell_dict = cell
# Deduplicate by ARFCN (keep strongest signal)
arfcn = cell_dict.get('arfcn')
existing = next(
(c for c in self._gsm_cells if c.get('arfcn') == arfcn),
None
)
if existing:
# Update if stronger signal
if cell_dict.get('power_dbm', -200) > existing.get('power_dbm', -200):
existing.update(cell_dict)
else:
self._gsm_cells.append(cell_dict)
def _compile_spectrum_baseline(
self,
band_name: str,
samples: list[BandMetrics]
) -> SpectrumBaseline:
"""Compile spectrum samples into a baseline."""
if not samples:
return SpectrumBaseline(
band_name=band_name,
freq_start_mhz=0,
freq_end_mhz=0,
noise_floor_db=-100,
avg_power_db=-100,
activity_score=0,
peak_frequencies=[],
)
# Average the noise floors
noise_floors = [s.noise_floor_db for s in samples]
avg_noise = mean(noise_floors)
# Average the power levels
avg_powers = [s.avg_power_db for s in samples]
avg_power = mean(avg_powers)
# Average activity scores
activity_scores = [s.activity_score for s in samples]
avg_activity = mean(activity_scores)
# Collect peak frequencies that appear consistently
all_peaks = [s.peak_frequency_mhz for s in samples]
# Group peaks within 0.1 MHz
peak_groups: dict[float, int] = {}
for peak in all_peaks:
# Find existing group
found = False
for group_freq in list(peak_groups.keys()):
if abs(peak - group_freq) < 0.1:
peak_groups[group_freq] += 1
found = True
break
if not found:
peak_groups[peak] = 1
# Keep peaks that appear in >50% of samples
threshold = len(samples) * 0.5
consistent_peaks = [
freq for freq, count in peak_groups.items()
if count >= threshold
]
return SpectrumBaseline(
band_name=band_name,
freq_start_mhz=samples[0].freq_start_mhz,
freq_end_mhz=samples[0].freq_end_mhz,
noise_floor_db=avg_noise,
avg_power_db=avg_power,
activity_score=avg_activity,
peak_frequencies=consistent_peaks,
sample_count=len(samples),
)
def _compile_cellular_baseline(self) -> CellularBaseline:
"""Compile cellular samples into a baseline."""
operators = list(set(
c.get('plmn') for c in self._cellular_samples
if c.get('plmn')
))
return CellularBaseline(
cells=self._cellular_samples.copy(),
operators=operators,
)
def _compile_tower_baseline(self) -> TowerBaseline:
"""Compile tower samples into a baseline."""
return TowerBaseline(
towers=self._tower_samples.copy(),
)
def compare_spectrum_baseline(
current: BandMetrics,
baseline: SpectrumBaseline | dict,
) -> dict:
"""
Compare current spectrum metrics to baseline.
Args:
current: Current band metrics
baseline: Baseline to compare against (SpectrumBaseline or dict)
Returns:
Dictionary with comparison results
"""
if isinstance(baseline, dict):
baseline = SpectrumBaseline.from_dict(baseline)
noise_delta = current.noise_floor_db - baseline.noise_floor_db
activity_delta = current.activity_score - baseline.activity_score
# Check if current peak is new
is_new_peak = all(
abs(current.peak_frequency_mhz - bp) > 0.1
for bp in baseline.peak_frequencies
) if baseline.peak_frequencies else False
return {
'band_name': current.band_name,
'noise_floor_current': current.noise_floor_db,
'noise_floor_baseline': baseline.noise_floor_db,
'noise_delta': noise_delta,
'activity_current': current.activity_score,
'activity_baseline': baseline.activity_score,
'activity_delta': activity_delta,
'peak_current': current.peak_frequency_mhz,
'is_new_peak': is_new_peak,
'baseline_peaks': baseline.peak_frequencies,
'anomaly_detected': (
abs(noise_delta) > 6 or
abs(activity_delta) > 30 or
is_new_peak
),
}
def compare_tower_baseline(
current_towers: list[CellTower | dict],
baseline: TowerBaseline | dict,
) -> dict:
"""
Compare current towers to baseline.
Args:
current_towers: List of current towers
baseline: Baseline to compare against
Returns:
Dictionary with comparison results
"""
if isinstance(baseline, dict):
baseline = TowerBaseline.from_dict(baseline)
# Get cell IDs from baseline
baseline_ids = set(t.get('cellid') for t in baseline.towers)
# Get current cell IDs
current_ids = set()
for tower in current_towers:
if isinstance(tower, CellTower):
current_ids.add(tower.cellid)
else:
current_ids.add(tower.get('cellid'))
new_towers = current_ids - baseline_ids
missing_towers = baseline_ids - current_ids
unchanged = current_ids & baseline_ids
return {
'total_current': len(current_ids),
'total_baseline': len(baseline_ids),
'new_tower_ids': list(new_towers),
'missing_tower_ids': list(missing_towers),
'unchanged_count': len(unchanged),
'new_count': len(new_towers),
'missing_count': len(missing_towers),
'anomaly_detected': len(new_towers) > 0,
}
def compare_cellular_baseline(
current_cells: list[dict],
baseline: CellularBaseline | dict,
) -> dict:
"""
Compare current cellular environment to baseline.
Args:
current_cells: List of current cell info dicts
baseline: Baseline to compare against
Returns:
Dictionary with comparison results
"""
if isinstance(baseline, dict):
baseline = CellularBaseline.from_dict(baseline)
# Get cell identifiers from baseline
baseline_cell_keys = set(
(c.get('cell_id'), c.get('plmn'))
for c in baseline.cells
)
# Get current cell identifiers
current_cell_keys = set(
(c.get('cell_id'), c.get('plmn'))
for c in current_cells
)
new_cells = current_cell_keys - baseline_cell_keys
missing_cells = baseline_cell_keys - current_cell_keys
# Check for new operators
current_operators = set(c.get('plmn') for c in current_cells if c.get('plmn'))
new_operators = current_operators - set(baseline.operators)
return {
'total_current': len(current_cell_keys),
'total_baseline': len(baseline_cell_keys),
'new_cells': list(new_cells),
'missing_cells': list(missing_cells),
'new_cell_count': len(new_cells),
'missing_cell_count': len(missing_cells),
'new_operators': list(new_operators),
'anomaly_detected': len(new_cells) > 0 or len(new_operators) > 0,
}
def save_baseline_to_db(
name: str,
location_name: str | None,
latitude: float | None,
longitude: float | None,
baseline_data: dict,
) -> int:
"""
Save baseline data to database.
Args:
name: Baseline name
location_name: Location description
latitude: GPS latitude
longitude: GPS longitude
baseline_data: Dict from BaselineRecorder.stop_recording()
Returns:
Database ID of created baseline
"""
return create_isms_baseline(
name=name,
location_name=location_name,
latitude=latitude,
longitude=longitude,
spectrum_profile=baseline_data.get('spectrum_profile'),
cellular_environment=baseline_data.get('cellular_environment'),
known_towers=baseline_data.get('known_towers'),
)

529
utils/isms/gsm.py Normal file
View File

@@ -0,0 +1,529 @@
"""
GSM cell detection using grgsm_scanner.
Provides passive GSM broadcast channel scanning to detect nearby
base stations and extract cell identity information.
"""
from __future__ import annotations
import logging
import re
import shutil
import subprocess
import threading
from dataclasses import dataclass, field
from datetime import datetime
from typing import Generator
logger = logging.getLogger('intercept.isms.gsm')
@dataclass
class GsmCell:
"""Detected GSM cell from broadcast channel."""
arfcn: int # Absolute Radio Frequency Channel Number
freq_mhz: float
power_dbm: float
mcc: int | None = None # Mobile Country Code
mnc: int | None = None # Mobile Network Code
lac: int | None = None # Location Area Code
cell_id: int | None = None
bsic: str | None = None # Base Station Identity Code
timestamp: datetime = field(default_factory=datetime.now)
@property
def plmn(self) -> str | None:
"""Public Land Mobile Network identifier (MCC-MNC)."""
if self.mcc is not None and self.mnc is not None:
return f'{self.mcc}-{self.mnc:02d}'
return None
@property
def cell_global_id(self) -> str | None:
"""Cell Global Identity string."""
if all(v is not None for v in [self.mcc, self.mnc, self.lac, self.cell_id]):
return f'{self.mcc}-{self.mnc:02d}-{self.lac}-{self.cell_id}'
return None
@dataclass
class GsmScanResult:
"""Result of a GSM scan."""
cells: list[GsmCell]
scan_duration_s: float
device_index: int
freq_start_mhz: float
freq_end_mhz: float
error: str | None = None
# GSM frequency bands (ARFCN to frequency mapping)
# GSM900: ARFCN 1-124 (Uplink: 890-915 MHz, Downlink: 935-960 MHz)
# GSM1800: ARFCN 512-885 (Uplink: 1710-1785 MHz, Downlink: 1805-1880 MHz)
def arfcn_to_freq(arfcn: int, band: str = 'auto') -> float:
"""
Convert ARFCN to downlink frequency in MHz.
Args:
arfcn: Absolute Radio Frequency Channel Number
band: Band hint ('gsm900', 'gsm1800', 'auto')
Returns:
Downlink frequency in MHz
"""
if band == 'auto':
if 1 <= arfcn <= 124:
band = 'gsm900'
elif 512 <= arfcn <= 885:
band = 'gsm1800'
elif 975 <= arfcn <= 1023:
band = 'egsm900' # Extended GSM900
else:
band = 'gsm900' # Default
if band in ('gsm900', 'p-gsm'):
# P-GSM 900: ARFCN 1-124
# Downlink = 935 + 0.2 * (ARFCN - 1)
if 1 <= arfcn <= 124:
return 935.0 + 0.2 * arfcn
elif arfcn == 0:
return 935.0
elif band == 'egsm900':
# E-GSM 900: ARFCN 975-1023, 0-124
if 975 <= arfcn <= 1023:
return 935.0 + 0.2 * (arfcn - 1024)
elif 0 <= arfcn <= 124:
return 935.0 + 0.2 * arfcn
elif band in ('gsm1800', 'dcs1800'):
# DCS 1800: ARFCN 512-885
# Downlink = 1805.2 + 0.2 * (ARFCN - 512)
if 512 <= arfcn <= 885:
return 1805.2 + 0.2 * (arfcn - 512)
elif band in ('gsm1900', 'pcs1900'):
# PCS 1900: ARFCN 512-810
# Downlink = 1930.2 + 0.2 * (ARFCN - 512)
if 512 <= arfcn <= 810:
return 1930.2 + 0.2 * (arfcn - 512)
# Fallback for unknown
return 935.0 + 0.2 * arfcn
def get_grgsm_scanner_path() -> str | None:
"""Get the path to grgsm_scanner executable."""
return shutil.which('grgsm_scanner')
def _drain_stderr(process: subprocess.Popen, stop_event: threading.Event) -> list[str]:
"""Drain stderr and collect error messages."""
errors = []
try:
while not stop_event.is_set() and process.poll() is None:
if process.stderr:
line = process.stderr.readline()
if line:
errors.append(line.strip())
except Exception:
pass
return errors
def parse_grgsm_output(line: str) -> GsmCell | None:
"""
Parse a line of grgsm_scanner output.
grgsm_scanner output format varies but typically includes:
ARFCN: XXX, Freq: XXX.X MHz, Power: -XX.X dBm, CID: XXXX, LAC: XXXX, MCC: XXX, MNC: XX
Args:
line: A line of grgsm_scanner output
Returns:
GsmCell if parsed successfully, None otherwise
"""
line = line.strip()
if not line:
return None
# Skip header/info lines
if line.startswith(('#', 'ARFCN', '---', 'gr-gsm', 'Using', 'Scanning')):
return None
# Pattern for typical grgsm_scanner output
# Example: "ARFCN: 73, Freq: 949.6M, CID: 12345, LAC: 1234, MCC: 234, MNC: 10, Pwr: -65.2"
arfcn_match = re.search(r'ARFCN[:\s]+(\d+)', line, re.IGNORECASE)
freq_match = re.search(r'Freq[:\s]+([\d.]+)\s*M', line, re.IGNORECASE)
power_match = re.search(r'(?:Pwr|Power)[:\s]+([-\d.]+)', line, re.IGNORECASE)
cid_match = re.search(r'CID[:\s]+(\d+)', line, re.IGNORECASE)
lac_match = re.search(r'LAC[:\s]+(\d+)', line, re.IGNORECASE)
mcc_match = re.search(r'MCC[:\s]+(\d+)', line, re.IGNORECASE)
mnc_match = re.search(r'MNC[:\s]+(\d+)', line, re.IGNORECASE)
bsic_match = re.search(r'BSIC[:\s]+([0-9,]+)', line, re.IGNORECASE)
# Alternative format: tab/comma separated values
# ARFCN Freq Pwr CID LAC MCC MNC BSIC
if not arfcn_match:
parts = re.split(r'[,\t]+', line)
if len(parts) >= 3:
try:
# Try to parse as numeric fields
arfcn = int(parts[0].strip())
freq = float(parts[1].strip().replace('M', ''))
power = float(parts[2].strip())
cell = GsmCell(
arfcn=arfcn,
freq_mhz=freq,
power_dbm=power,
)
if len(parts) > 3:
cell.cell_id = int(parts[3].strip()) if parts[3].strip().isdigit() else None
if len(parts) > 4:
cell.lac = int(parts[4].strip()) if parts[4].strip().isdigit() else None
if len(parts) > 5:
cell.mcc = int(parts[5].strip()) if parts[5].strip().isdigit() else None
if len(parts) > 6:
cell.mnc = int(parts[6].strip()) if parts[6].strip().isdigit() else None
return cell
except (ValueError, IndexError):
pass
if not arfcn_match:
return None
arfcn = int(arfcn_match.group(1))
# Get frequency from output or calculate from ARFCN
if freq_match:
freq_mhz = float(freq_match.group(1))
else:
freq_mhz = arfcn_to_freq(arfcn)
# Get power (default to weak if not found)
if power_match:
power_dbm = float(power_match.group(1))
else:
power_dbm = -100.0
cell = GsmCell(
arfcn=arfcn,
freq_mhz=freq_mhz,
power_dbm=power_dbm,
)
if cid_match:
cell.cell_id = int(cid_match.group(1))
if lac_match:
cell.lac = int(lac_match.group(1))
if mcc_match:
cell.mcc = int(mcc_match.group(1))
if mnc_match:
cell.mnc = int(mnc_match.group(1))
if bsic_match:
cell.bsic = bsic_match.group(1)
return cell
def run_grgsm_scan(
band: str = 'GSM900',
device_index: int = 0,
gain: int = 40,
ppm: int = 0,
speed: int = 4,
timeout: float = 60.0,
) -> Generator[GsmCell, None, None]:
"""
Run grgsm_scanner and yield detected GSM cells.
Args:
band: GSM band to scan ('GSM900', 'GSM1800', 'GSM850', 'GSM1900')
device_index: RTL-SDR device index
gain: Gain in dB
ppm: Frequency correction in PPM
speed: Scan speed (1-5, higher is faster but less accurate)
timeout: Maximum scan duration in seconds
Yields:
GsmCell objects for each detected cell
"""
grgsm_scanner = get_grgsm_scanner_path()
if not grgsm_scanner:
logger.error("grgsm_scanner not found in PATH")
return
# Map band names to grgsm_scanner arguments
band_args = {
'GSM900': ['--band', 'P-GSM'],
'EGSM900': ['--band', 'E-GSM'],
'GSM1800': ['--band', 'DCS1800'],
'GSM850': ['--band', 'GSM850'],
'GSM1900': ['--band', 'PCS1900'],
}
band_arg = band_args.get(band.upper(), ['--band', 'P-GSM'])
cmd = [
grgsm_scanner,
*band_arg,
'-g', str(gain),
'-p', str(ppm),
'-s', str(speed),
'-v', # Verbose output for more cell details
]
logger.info(f"Starting grgsm_scanner: {' '.join(cmd)}")
stop_event = threading.Event()
stderr_thread = None
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
# Drain stderr in background
stderr_thread = threading.Thread(
target=_drain_stderr,
args=(process, stop_event),
daemon=True
)
stderr_thread.start()
# Set up timeout
import time
start_time = time.time()
# Parse output line by line
for line in iter(process.stdout.readline, ''):
if time.time() - start_time > timeout:
logger.info(f"grgsm_scanner timeout after {timeout}s")
break
cell = parse_grgsm_output(line)
if cell:
yield cell
# Terminate if still running
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
except Exception as e:
logger.error(f"grgsm_scanner error: {e}")
finally:
stop_event.set()
if stderr_thread:
stderr_thread.join(timeout=1.0)
def run_gsm_scan_blocking(
band: str = 'GSM900',
device_index: int = 0,
gain: int = 40,
ppm: int = 0,
speed: int = 4,
timeout: float = 60.0,
) -> GsmScanResult:
"""
Run a complete GSM scan and return all results.
Args:
band: GSM band to scan
device_index: RTL-SDR device index
gain: Gain in dB
ppm: Frequency correction in PPM
speed: Scan speed (1-5)
timeout: Maximum scan duration
Returns:
GsmScanResult with all detected cells
"""
import time
start_time = time.time()
cells: list[GsmCell] = []
error: str | None = None
try:
for cell in run_grgsm_scan(
band=band,
device_index=device_index,
gain=gain,
ppm=ppm,
speed=speed,
timeout=timeout,
):
cells.append(cell)
except Exception as e:
error = str(e)
logger.error(f"GSM scan error: {e}")
duration = time.time() - start_time
# Determine frequency range based on band
freq_ranges = {
'GSM900': (935.0, 960.0),
'EGSM900': (925.0, 960.0),
'GSM1800': (1805.0, 1880.0),
'GSM850': (869.0, 894.0),
'GSM1900': (1930.0, 1990.0),
}
freq_start, freq_end = freq_ranges.get(band.upper(), (935.0, 960.0))
return GsmScanResult(
cells=cells,
scan_duration_s=duration,
device_index=device_index,
freq_start_mhz=freq_start,
freq_end_mhz=freq_end,
error=error,
)
def format_gsm_cell(cell: GsmCell) -> dict:
"""Format GSM cell for JSON output."""
return {
'arfcn': cell.arfcn,
'freq_mhz': round(cell.freq_mhz, 1),
'power_dbm': round(cell.power_dbm, 1),
'mcc': cell.mcc,
'mnc': cell.mnc,
'lac': cell.lac,
'cell_id': cell.cell_id,
'bsic': cell.bsic,
'plmn': cell.plmn,
'cell_global_id': cell.cell_global_id,
'timestamp': cell.timestamp.isoformat() + 'Z',
}
def deduplicate_cells(cells: list[GsmCell]) -> list[GsmCell]:
"""
Deduplicate cells by ARFCN, keeping strongest signal.
Args:
cells: List of detected cells
Returns:
Deduplicated list with strongest signal per ARFCN
"""
best_cells: dict[int, GsmCell] = {}
for cell in cells:
if cell.arfcn not in best_cells:
best_cells[cell.arfcn] = cell
elif cell.power_dbm > best_cells[cell.arfcn].power_dbm:
best_cells[cell.arfcn] = cell
return sorted(best_cells.values(), key=lambda c: c.power_dbm, reverse=True)
def get_uk_operator_name(mcc: int | None, mnc: int | None) -> str | None:
"""Get UK mobile operator name from MCC/MNC."""
if mcc != 234: # UK MCC
return None
uk_operators = {
10: 'O2',
15: 'Vodafone',
20: 'Three',
30: 'EE',
31: 'EE',
32: 'EE',
33: 'EE',
34: 'EE',
50: 'JT',
55: 'Sure',
58: 'Manx Telecom',
}
return uk_operators.get(mnc)
def identify_gsm_anomalies(
current_cells: list[GsmCell],
baseline_cells: list[GsmCell] | None = None,
) -> list[dict]:
"""
Identify potential anomalies in GSM environment.
Checks for:
- New cells not in baseline
- Cells with unusually strong signals
- Cells with suspicious MCC/MNC combinations
- Missing expected cells from baseline
Args:
current_cells: Currently detected cells
baseline_cells: Optional baseline for comparison
Returns:
List of anomaly findings
"""
anomalies = []
current_arfcns = {c.arfcn for c in current_cells}
# Check for very strong signals (potential nearby transmitter)
for cell in current_cells:
if cell.power_dbm > -40:
anomalies.append({
'type': 'strong_signal',
'severity': 'warn',
'description': f'Unusually strong GSM signal on ARFCN {cell.arfcn} ({cell.power_dbm:.1f} dBm)',
'cell': format_gsm_cell(cell),
})
if baseline_cells:
baseline_arfcns = {c.arfcn for c in baseline_cells}
baseline_cids = {c.cell_global_id for c in baseline_cells if c.cell_global_id}
# New ARFCNs not in baseline
new_arfcns = current_arfcns - baseline_arfcns
for arfcn in new_arfcns:
cell = next((c for c in current_cells if c.arfcn == arfcn), None)
if cell:
anomalies.append({
'type': 'new_arfcn',
'severity': 'info',
'description': f'New ARFCN {arfcn} detected ({cell.freq_mhz:.1f} MHz, {cell.power_dbm:.1f} dBm)',
'cell': format_gsm_cell(cell),
})
# Missing ARFCNs from baseline
missing_arfcns = baseline_arfcns - current_arfcns
for arfcn in missing_arfcns:
baseline_cell = next((c for c in baseline_cells if c.arfcn == arfcn), None)
if baseline_cell:
anomalies.append({
'type': 'missing_arfcn',
'severity': 'info',
'description': f'Expected ARFCN {arfcn} not detected (was {baseline_cell.power_dbm:.1f} dBm)',
'cell': format_gsm_cell(baseline_cell),
})
# Check for new cell IDs on existing ARFCNs (potential fake base station)
for cell in current_cells:
if cell.cell_global_id and cell.cell_global_id not in baseline_cids:
if cell.arfcn in baseline_arfcns:
anomalies.append({
'type': 'new_cell_id',
'severity': 'warn',
'description': f'New Cell ID on existing ARFCN {cell.arfcn}: {cell.cell_global_id}',
'cell': format_gsm_cell(cell),
})
return anomalies

401
utils/isms/rules.py Normal file
View File

@@ -0,0 +1,401 @@
"""
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())

416
utils/isms/spectrum.py Normal file
View File

@@ -0,0 +1,416 @@
"""
Spectrum analysis using rtl_power.
Provides functions to scan RF spectrum, compute band metrics,
and detect signal anomalies.
"""
from __future__ import annotations
import csv
import io
import logging
import shutil
import subprocess
import tempfile
import threading
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from statistics import mean, stdev
from typing import Generator
logger = logging.getLogger('intercept.isms.spectrum')
@dataclass
class SpectrumBin:
"""A single frequency bin from rtl_power output."""
freq_hz: float
power_db: float
timestamp: datetime
freq_start: float = 0.0
freq_end: float = 0.0
@property
def freq_mhz(self) -> float:
"""Frequency in MHz."""
return self.freq_hz / 1_000_000
@dataclass
class BandMetrics:
"""Computed metrics for a frequency band."""
band_name: str
freq_start_mhz: float
freq_end_mhz: float
noise_floor_db: float
peak_frequency_mhz: float
peak_power_db: float
activity_score: float # 0-100 based on variance/peaks above noise
bin_count: int = 0
avg_power_db: float = 0.0
power_variance: float = 0.0
peaks_above_threshold: int = 0
@dataclass
class BurstEvent:
"""Detected burst/transient signal."""
freq_mhz: float
power_db: float
timestamp: datetime
duration_estimate: float = 0.0
above_noise_db: float = 0.0
def get_rtl_power_path() -> str | None:
"""Get the path to rtl_power executable."""
return shutil.which('rtl_power')
def _drain_stderr(process: subprocess.Popen, stop_event: threading.Event) -> None:
"""Drain stderr to prevent buffer deadlock."""
try:
while not stop_event.is_set() and process.poll() is None:
if process.stderr:
process.stderr.read(1024)
except Exception:
pass
def run_rtl_power_scan(
freq_start_mhz: float,
freq_end_mhz: float,
bin_size_hz: int = 10000,
integration_time: float = 1.0,
device_index: int = 0,
gain: int = 40,
ppm: int = 0,
single_shot: bool = False,
output_file: Path | None = None,
) -> Generator[SpectrumBin, None, None]:
"""
Run rtl_power and yield spectrum bins.
Args:
freq_start_mhz: Start frequency in MHz
freq_end_mhz: End frequency in MHz
bin_size_hz: Frequency bin size in Hz
integration_time: Integration time per sweep in seconds
device_index: RTL-SDR device index
gain: Gain in dB (0 for auto)
ppm: Frequency correction in PPM
single_shot: If True, exit after one complete sweep
output_file: Optional file to write CSV output
Yields:
SpectrumBin objects for each frequency bin
"""
rtl_power = get_rtl_power_path()
if not rtl_power:
logger.error("rtl_power not found in PATH")
return
# Build command
freq_range = f'{freq_start_mhz}M:{freq_end_mhz}M:{bin_size_hz}'
cmd = [
rtl_power,
'-f', freq_range,
'-i', str(integration_time),
'-d', str(device_index),
'-g', str(gain),
'-p', str(ppm),
]
if single_shot:
cmd.extend(['-1']) # Single shot mode
# Use temp file if not provided
if output_file is None:
temp_fd, temp_path = tempfile.mkstemp(suffix='.csv', prefix='rtl_power_')
output_file = Path(temp_path)
cleanup_temp = True
else:
cleanup_temp = False
cmd.extend(['-c', '0']) # Continuous output to stdout
logger.info(f"Starting rtl_power: {' '.join(cmd)}")
stop_event = threading.Event()
stderr_thread = None
try:
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
# Drain stderr in background to prevent deadlock
stderr_thread = threading.Thread(
target=_drain_stderr,
args=(process, stop_event),
daemon=True
)
stderr_thread.start()
# Parse CSV output line by line
# rtl_power format: date, time, freq_low, freq_high, step, samples, db_values...
for line in iter(process.stdout.readline, ''):
line = line.strip()
if not line:
continue
try:
parts = line.split(',')
if len(parts) < 7:
continue
# Parse timestamp
date_str = parts[0].strip()
time_str = parts[1].strip()
try:
timestamp = datetime.strptime(
f'{date_str} {time_str}',
'%Y-%m-%d %H:%M:%S'
)
except ValueError:
timestamp = datetime.now()
# Parse frequency range
freq_low = float(parts[2])
freq_high = float(parts[3])
freq_step = float(parts[4])
# samples = int(parts[5])
# Parse power values
db_values = [float(v) for v in parts[6:] if v.strip()]
# Yield each bin
current_freq = freq_low
for db_value in db_values:
yield SpectrumBin(
freq_hz=current_freq,
power_db=db_value,
timestamp=timestamp,
freq_start=freq_low,
freq_end=freq_high,
)
current_freq += freq_step
except (ValueError, IndexError) as e:
logger.debug(f"Failed to parse rtl_power line: {e}")
continue
except Exception as e:
logger.error(f"rtl_power error: {e}")
finally:
stop_event.set()
if stderr_thread:
stderr_thread.join(timeout=1.0)
if cleanup_temp and output_file.exists():
output_file.unlink()
def compute_band_metrics(
bins: list[SpectrumBin],
band_name: str = 'Unknown',
noise_percentile: float = 10.0,
activity_threshold_db: float = 6.0,
) -> BandMetrics:
"""
Compute metrics from spectrum bins.
Args:
bins: List of SpectrumBin objects
band_name: Name for this band
noise_percentile: Percentile to use for noise floor estimation
activity_threshold_db: dB above noise to count as activity
Returns:
BandMetrics with computed values
"""
if not bins:
return BandMetrics(
band_name=band_name,
freq_start_mhz=0,
freq_end_mhz=0,
noise_floor_db=-100,
peak_frequency_mhz=0,
peak_power_db=-100,
activity_score=0,
)
powers = [b.power_db for b in bins]
freqs = [b.freq_mhz for b in bins]
# Sort for percentile calculation
sorted_powers = sorted(powers)
noise_idx = int(len(sorted_powers) * noise_percentile / 100)
noise_floor = sorted_powers[noise_idx] if noise_idx < len(sorted_powers) else sorted_powers[0]
# Find peak
peak_idx = powers.index(max(powers))
peak_power = powers[peak_idx]
peak_freq = freqs[peak_idx]
# Calculate activity score
# Based on: variance of power levels and count of peaks above threshold
threshold = noise_floor + activity_threshold_db
peaks_above = sum(1 for p in powers if p > threshold)
# Calculate variance
try:
power_var = stdev(powers) ** 2 if len(powers) > 1 else 0
except Exception:
power_var = 0
# Activity score: combination of peak ratio and variance
peak_ratio = peaks_above / len(bins) if bins else 0
# Normalize variance (typical range 0-100 dB^2)
var_component = min(power_var / 100, 1.0)
# Weighted combination
activity_score = min(100, (peak_ratio * 70 + var_component * 30))
return BandMetrics(
band_name=band_name,
freq_start_mhz=min(freqs),
freq_end_mhz=max(freqs),
noise_floor_db=noise_floor,
peak_frequency_mhz=peak_freq,
peak_power_db=peak_power,
activity_score=activity_score,
bin_count=len(bins),
avg_power_db=mean(powers) if powers else -100,
power_variance=power_var,
peaks_above_threshold=peaks_above,
)
def detect_bursts(
bins: list[SpectrumBin],
threshold_db: float = 10.0,
min_power_db: float = -80.0,
noise_floor_db: float | None = None,
) -> list[BurstEvent]:
"""
Detect short bursts above noise floor.
Args:
bins: List of SpectrumBin objects (should be time-ordered for one frequency)
threshold_db: dB above noise to consider a burst
min_power_db: Minimum absolute power to consider
noise_floor_db: Noise floor (computed if not provided)
Returns:
List of detected BurstEvent objects
"""
if not bins:
return []
# Estimate noise floor if not provided
if noise_floor_db is None:
sorted_powers = sorted(b.power_db for b in bins)
noise_idx = int(len(sorted_powers) * 0.1) # 10th percentile
noise_floor_db = sorted_powers[noise_idx]
threshold = noise_floor_db + threshold_db
threshold = max(threshold, min_power_db)
bursts = []
for bin_data in bins:
if bin_data.power_db > threshold:
bursts.append(BurstEvent(
freq_mhz=bin_data.freq_mhz,
power_db=bin_data.power_db,
timestamp=bin_data.timestamp,
above_noise_db=bin_data.power_db - noise_floor_db,
))
return bursts
def parse_rtl_power_csv(csv_path: Path) -> list[SpectrumBin]:
"""
Parse an rtl_power CSV file.
Args:
csv_path: Path to CSV file
Returns:
List of SpectrumBin objects
"""
bins = []
with open(csv_path, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
parts = line.split(',')
if len(parts) < 7:
continue
date_str = parts[0].strip()
time_str = parts[1].strip()
try:
timestamp = datetime.strptime(
f'{date_str} {time_str}',
'%Y-%m-%d %H:%M:%S'
)
except ValueError:
timestamp = datetime.now()
freq_low = float(parts[2])
freq_step = float(parts[4])
db_values = [float(v) for v in parts[6:] if v.strip()]
current_freq = freq_low
for db_value in db_values:
bins.append(SpectrumBin(
freq_hz=current_freq,
power_db=db_value,
timestamp=timestamp,
))
current_freq += freq_step
except (ValueError, IndexError):
continue
return bins
def group_bins_by_band(
bins: list[SpectrumBin],
band_ranges: dict[str, tuple[float, float]],
) -> dict[str, list[SpectrumBin]]:
"""
Group spectrum bins by predefined band ranges.
Args:
bins: List of SpectrumBin objects
band_ranges: Dict mapping band name to (start_mhz, end_mhz)
Returns:
Dict mapping band name to list of bins in that band
"""
grouped: dict[str, list[SpectrumBin]] = {name: [] for name in band_ranges}
for bin_data in bins:
freq_mhz = bin_data.freq_mhz
for band_name, (start, end) in band_ranges.items():
if start <= freq_mhz <= end:
grouped[band_name].append(bin_data)
break
return grouped

340
utils/isms/towers.py Normal file
View File

@@ -0,0 +1,340 @@
"""
Cell tower integration via OpenCelliD API.
Provides functions to query nearby towers and generate link-outs
to CellMapper and Ofcom resources.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from math import acos, cos, radians, sin
import requests
logger = logging.getLogger('intercept.isms.towers')
# OpenCelliD API endpoint
OPENCELLID_API_URL = 'https://opencellid.org/cell/getInArea'
# Request timeout
REQUEST_TIMEOUT = 10.0
@dataclass
class CellTower:
"""Cell tower information from OpenCelliD."""
tower_id: int
mcc: int
mnc: int
lac: int
cellid: int
lat: float
lon: float
range_m: int
radio: str # GSM, UMTS, LTE, NR
samples: int = 0
changeable: bool = True
created: int = 0
updated: int = 0
@property
def plmn(self) -> str:
"""Get PLMN code (MCC-MNC)."""
return f'{self.mcc}-{self.mnc}'
@property
def distance_km(self) -> float | None:
"""Distance from query point (set after query)."""
return getattr(self, '_distance_km', None)
@distance_km.setter
def distance_km(self, value: float) -> None:
self._distance_km = value
def to_dict(self) -> dict:
"""Convert to dictionary."""
return {
'tower_id': self.tower_id,
'mcc': self.mcc,
'mnc': self.mnc,
'lac': self.lac,
'cellid': self.cellid,
'lat': self.lat,
'lon': self.lon,
'range_m': self.range_m,
'radio': self.radio,
'plmn': self.plmn,
'samples': self.samples,
'distance_km': self.distance_km,
'cellmapper_url': build_cellmapper_url(self.mcc, self.mnc, self.lac, self.cellid),
}
def get_opencellid_token() -> str | None:
"""Get OpenCelliD API token from environment."""
return os.environ.get('OPENCELLID_TOKEN')
def _haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points in km using Haversine formula."""
R = 6371 # Earth radius in km
lat1_rad = radians(lat1)
lat2_rad = radians(lat2)
lon1_rad = radians(lon1)
lon2_rad = radians(lon2)
dlat = lat2_rad - lat1_rad
dlon = lon2_rad - lon1_rad
# Haversine formula
a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2
c = 2 * acos(min(1, (1 - a) ** 0.5 * (1 + a) ** 0.5 + a ** 0.5 * (1 - a) ** 0.5))
# Simplified: c = 2 * atan2(sqrt(a), sqrt(1-a))
# Using identity: acos(1 - 2a) for small angles
return R * c
def query_nearby_towers(
lat: float,
lon: float,
radius_km: float = 5.0,
token: str | None = None,
radio: str | None = None,
mcc: int | None = None,
mnc: int | None = None,
) -> list[CellTower]:
"""
Query OpenCelliD for towers within radius.
Args:
lat: Latitude of center point
lon: Longitude of center point
radius_km: Search radius in kilometers
token: OpenCelliD API token (uses env var if not provided)
radio: Filter by radio type (GSM, UMTS, LTE, NR)
mcc: Filter by MCC (country code)
mnc: Filter by MNC (network code)
Returns:
List of CellTower objects sorted by distance
"""
if token is None:
token = get_opencellid_token()
if not token:
logger.warning("OpenCelliD token not configured")
return []
# Convert radius to bounding box
# Approximate: 1 degree latitude ~ 111 km
lat_delta = radius_km / 111.0
# Longitude varies with latitude
lon_delta = radius_km / (111.0 * cos(radians(lat)))
params = {
'key': token,
'BBOX': f'{lon - lon_delta},{lat - lat_delta},{lon + lon_delta},{lat + lat_delta}',
'format': 'json',
}
if radio:
params['radio'] = radio
if mcc is not None:
params['mcc'] = mcc
if mnc is not None:
params['mnc'] = mnc
try:
response = requests.get(
OPENCELLID_API_URL,
params=params,
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if 'cells' not in data:
logger.debug(f"No cells in OpenCelliD response: {data}")
return []
towers = []
for cell in data['cells']:
try:
tower = CellTower(
tower_id=cell.get('cellid', 0),
mcc=cell.get('mcc', 0),
mnc=cell.get('mnc', 0),
lac=cell.get('lac', 0),
cellid=cell.get('cellid', 0),
lat=cell.get('lat', 0),
lon=cell.get('lon', 0),
range_m=cell.get('range', 0),
radio=cell.get('radio', 'UNKNOWN'),
samples=cell.get('samples', 0),
changeable=cell.get('changeable', True),
created=cell.get('created', 0),
updated=cell.get('updated', 0),
)
# Calculate distance
distance = _haversine_distance(lat, lon, tower.lat, tower.lon)
tower.distance_km = round(distance, 2)
# Only include towers within actual radius (bounding box is larger)
if distance <= radius_km:
towers.append(tower)
except (KeyError, TypeError) as e:
logger.debug(f"Failed to parse cell: {e}")
continue
# Sort by distance
towers.sort(key=lambda t: t.distance_km or 0)
logger.info(f"Found {len(towers)} towers within {radius_km}km of ({lat}, {lon})")
return towers
except requests.RequestException as e:
logger.error(f"OpenCelliD API error: {e}")
return []
except Exception as e:
logger.error(f"Error querying towers: {e}")
return []
def build_cellmapper_url(mcc: int, mnc: int, lac: int, cid: int) -> str:
"""
Build link-out URL to CellMapper (no scraping).
Args:
mcc: Mobile Country Code
mnc: Mobile Network Code
lac: Location Area Code
cid: Cell ID
Returns:
URL to CellMapper map view for this cell
"""
return f'https://www.cellmapper.net/map?MCC={mcc}&MNC={mnc}&LAC={lac}&CID={cid}'
def build_cellmapper_tower_url(mcc: int, mnc: int, lat: float, lon: float) -> str:
"""
Build link-out URL to CellMapper map centered on location.
Args:
mcc: Mobile Country Code
mnc: Mobile Network Code
lat: Latitude
lon: Longitude
Returns:
URL to CellMapper map view centered on location
"""
return f'https://www.cellmapper.net/map?MCC={mcc}&MNC={mnc}&latitude={lat}&longitude={lon}&zoom=15'
def build_ofcom_coverage_url(lat: float | None = None, lon: float | None = None) -> str:
"""
Build link to Ofcom mobile coverage checker.
Args:
lat: Optional latitude for location
lon: Optional longitude for location
Returns:
URL to Ofcom coverage checker
"""
base_url = 'https://www.ofcom.org.uk/phones-and-broadband/coverage-and-quality/mobile-coverage-checker'
# Note: Ofcom coverage checker uses postcode entry, not lat/lon parameters
# So we just return the base URL
return base_url
def build_ofcom_emf_url() -> str:
"""
Build link to Ofcom EMF/base station audits info.
Returns:
URL to Ofcom EMF information page
"""
return 'https://www.ofcom.org.uk/phones-telecoms-and-internet/information-for-industry/radiocomms-and-spectrum/radio-spectrum/spectrum-for-mobile-services/electromagnetic-fields-emf'
def build_ofcom_sitefinder_url() -> str:
"""
Build link to Ofcom Sitefinder (base station database).
Note: Sitefinder was retired in 2017. This returns the info page.
Returns:
URL to Ofcom mobile sites information
"""
return 'https://www.ofcom.org.uk/phones-telecoms-and-internet/advice-for-consumers/mobile-services'
def get_uk_operator_name(mcc: int, mnc: int) -> str | None:
"""
Get UK operator name from MCC/MNC.
Args:
mcc: Mobile Country Code
mnc: Mobile Network Code
Returns:
Operator name or None if not found
"""
# UK MCC is 234
if mcc != 234:
return None
operators = {
10: 'O2 UK',
15: 'Vodafone UK',
20: 'Three UK',
30: 'EE',
33: 'EE',
34: 'EE',
50: 'JT (Jersey)',
55: 'Sure (Guernsey)',
}
return operators.get(mnc)
def format_tower_info(tower: CellTower) -> dict:
"""
Format tower information for display.
Args:
tower: CellTower object
Returns:
Formatted dictionary with display-friendly values
"""
operator = get_uk_operator_name(tower.mcc, tower.mnc)
return {
'id': tower.tower_id,
'plmn': tower.plmn,
'operator': operator or f'MCC {tower.mcc} / MNC {tower.mnc}',
'radio': tower.radio,
'lac': tower.lac,
'cellid': tower.cellid,
'lat': round(tower.lat, 6),
'lon': round(tower.lon, 6),
'range_km': round(tower.range_m / 1000, 2) if tower.range_m else None,
'distance_km': tower.distance_km,
'samples': tower.samples,
'cellmapper_url': build_cellmapper_url(tower.mcc, tower.mnc, tower.lac, tower.cellid),
'ofcom_coverage_url': build_ofcom_coverage_url(),
'ofcom_emf_url': build_ofcom_emf_url(),
}