diff --git a/routes/tscm/analysis.py b/routes/tscm/analysis.py index 3dca547..9b09c74 100644 --- a/routes/tscm/analysis.py +++ b/routes/tscm/analysis.py @@ -28,6 +28,22 @@ from utils.tscm.correlation import get_correlation_engine logger = logging.getLogger('intercept.tscm') +_VALID_CATEGORIES = {'high_interest', 'needs_review', 'informational'} + + +def _parse_categories_param(raw: str) -> list[str] | None: + """Parse a comma-separated `categories` query param into a validated list. + + Returns None (meaning "all") when the param is absent or covers all three + categories, so callers can skip filtering in the common case. + """ + if not raw: + return None + cats = [c.strip() for c in raw.split(',') if c.strip() in _VALID_CATEGORIES] + if not cats or set(cats) == _VALID_CATEGORIES: + return None + return cats + # ============================================================================= # Threat Endpoints @@ -264,6 +280,8 @@ def get_pdf_report(): if not sweep: return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + categories = _parse_categories_param(request.args.get('categories', '')) + # Get data for report correlation = get_correlation_engine() profiles = [p.to_dict() for p in correlation.device_profiles.values()] @@ -278,7 +296,8 @@ def get_pdf_report(): sweep_data=sweep, device_profiles=profiles, capabilities=caps, - timelines=timelines + timelines=timelines, + categories=categories, ) pdf_content = get_pdf_report(report) @@ -319,6 +338,8 @@ def get_technical_annex(): if not sweep: return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 + categories = _parse_categories_param(request.args.get('categories', '')) + # Get data for report correlation = get_correlation_engine() profiles = [p.to_dict() for p in correlation.device_profiles.values()] @@ -333,7 +354,8 @@ def get_technical_annex(): sweep_data=sweep, device_profiles=profiles, capabilities=caps, - timelines=timelines + timelines=timelines, + categories=categories, ) if format_type == 'csv': diff --git a/templates/index.html b/templates/index.html index 75252b6..5997f30 100644 --- a/templates/index.html +++ b/templates/index.html @@ -12260,6 +12260,7 @@ // Clear and reset the signal timeline for new sweep SignalTimeline.clear(); document.getElementById('tscmReportBtn').style.display = 'none'; + document.getElementById('tscmReportCategoryFilter').style.display = 'none'; // Show warnings if any devices unavailable if (scanResult.warnings && scanResult.warnings.length > 0) { @@ -12361,9 +12362,10 @@ document.getElementById('stopTscmBtn').style.display = 'none'; document.getElementById('tscmProgress').style.display = 'none'; - // Show report button if we have any data + // Show report button and category filter if we have any data const hasData = tscmWifiDevices.length > 0 || tscmBtDevices.length > 0 || tscmRfSignals.length > 0; document.getElementById('tscmReportBtn').style.display = hasData ? 'block' : 'none'; + document.getElementById('tscmReportCategoryFilter').style.display = hasData ? 'block' : 'none'; return postStopRequest(endpoint, timeoutMs); } @@ -12383,18 +12385,30 @@ ...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' })) ]; - const highInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6); - const needsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6)); - const informational = allDevices.filter(d => d.classification === 'informational' || d.score < 3); + const allHighInterest = allDevices.filter(d => d.classification === 'high_interest' || d.score >= 6); + const allNeedsReview = allDevices.filter(d => d.classification === 'review' || (d.score >= 3 && d.score < 6)); + const allInformational = allDevices.filter(d => d.classification === 'informational' || d.score < 3); - // Determine overall assessment + // Apply category filter from sidebar checkboxes + const incHighInterest = document.getElementById('tscmCatHighInterest')?.checked ?? true; + const incNeedsReview = document.getElementById('tscmCatNeedsReview')?.checked ?? true; + const incInformational = document.getElementById('tscmCatInformational')?.checked ?? false; + const highInterest = incHighInterest ? allHighInterest : []; + const needsReview = incNeedsReview ? allNeedsReview : []; + const informational = incInformational ? allInformational : []; + + const categoryNote = (!incHighInterest || !incNeedsReview || !incInformational) + ? `
Filtered: showing ${[incHighInterest && 'High Interest', incNeedsReview && 'Needs Review', incInformational && 'Informational'].filter(Boolean).join(', ') || 'none'} only
` + : ''; + + // Determine overall assessment (based on all data, not filter) let assessment = 'LOW CONCERN'; let assessmentClass = 'informational'; - if (highInterest.length > 0) { - assessment = `ELEVATED CONCERN: ${highInterest.length} high-interest item(s) detected requiring immediate attention`; + if (allHighInterest.length > 0) { + assessment = `ELEVATED CONCERN: ${allHighInterest.length} high-interest item(s) detected requiring immediate attention`; assessmentClass = 'high-interest'; - } else if (needsReview.length > 0) { - assessment = `MODERATE CONCERN: ${needsReview.length} item(s) requiring further review`; + } else if (allNeedsReview.length > 0) { + assessment = `MODERATE CONCERN: ${allNeedsReview.length} item(s) requiring further review`; assessmentClass = 'needs-review'; } else { assessment = 'LOW CONCERN: No anomalies flagged by automated scan. Manual inspection recommended for comprehensive assessment.'; @@ -12765,6 +12779,7 @@
Assessment: ${assessment}
+ ${categoryNote} ${highInterest.length > 0 ? ` @@ -16216,9 +16231,18 @@ } // Report Downloads + function _tscmCategoryParam() { + const cats = []; + if (document.getElementById('tscmCatHighInterest')?.checked) cats.push('high_interest'); + if (document.getElementById('tscmCatNeedsReview')?.checked) cats.push('needs_review'); + if (document.getElementById('tscmCatInformational')?.checked) cats.push('informational'); + return cats.length < 3 ? `categories=${cats.join(',')}` : ''; + } + async function tscmDownloadPdf() { try { - const response = await fetch('/tscm/report/pdf'); + const catParam = _tscmCategoryParam(); + const response = await fetch(`/tscm/report/pdf${catParam ? '?' + catParam : ''}`); if (response.ok) { const blob = await response.blob(); const url = URL.createObjectURL(blob); @@ -16241,7 +16265,8 @@ async function tscmDownloadAnnex(format) { try { - const response = await fetch(`/tscm/report/annex?format=${format}`); + const catParam = _tscmCategoryParam(); + const response = await fetch(`/tscm/report/annex?format=${format}${catParam ? '&' + catParam : ''}`); if (response.ok) { const blob = await response.blob(); const url = URL.createObjectURL(blob); diff --git a/templates/partials/modes/tscm.html b/templates/partials/modes/tscm.html index c0814ac..0084eb0 100644 --- a/templates/partials/modes/tscm.html +++ b/templates/partials/modes/tscm.html @@ -101,6 +101,23 @@ + + + diff --git a/tests/test_signalid_match.py b/tests/test_signalid_match.py index d95523c..2856955 100644 --- a/tests/test_signalid_match.py +++ b/tests/test_signalid_match.py @@ -113,9 +113,9 @@ class TestMatchSignals: def test_multi_range_signal_matched_by_any_range(self): from utils.signal_db import match_signals # POCSAG has ranges in 138-175 MHz and 450-470 MHz - # 162 MHz is in the first range (maritime VHF area, but also POCSAG territory) - results_vhf = match_signals(frequency_mhz=155.0) - results_uhf = match_signals(frequency_mhz=455.0) + # Use a high limit so the expanded DB doesn't push it out of the window + results_vhf = match_signals(frequency_mhz=155.0, limit=20) + results_uhf = match_signals(frequency_mhz=455.0, limit=20) vhf_names = [r["name"] for r in results_vhf] uhf_names = [r["name"] for r in results_uhf] assert "POCSAG Pager" in vhf_names @@ -124,7 +124,8 @@ class TestMatchSignals: def test_region_mismatch_does_not_exclude_signal(self): from utils.signal_db import match_signals # PMR446 is EU/UK only; should still appear with US region but may score lower - results = match_signals(frequency_mhz=446.09375, region="US") + # Use a high limit since the expanded DB has more signals at 446 MHz + results = match_signals(frequency_mhz=446.09375, region="US", limit=20) names = [r["name"] for r in results] assert "PMR446 (Licence-Free UHF)" in names diff --git a/utils/tscm/reports.py b/utils/tscm/reports.py index 1d5f84f..61a0b3c 100644 --- a/utils/tscm/reports.py +++ b/utils/tscm/reports.py @@ -95,14 +95,14 @@ class TSCMReport: # Meeting window summaries meeting_summaries: list[ReportMeetingSummary] = field(default_factory=list) - # Statistics - total_devices_scanned: int = 0 - wifi_devices: int = 0 - wifi_clients: int = 0 - bluetooth_devices: int = 0 - rf_signals: int = 0 - new_devices: int = 0 - missing_devices: int = 0 + # Statistics + total_devices_scanned: int = 0 + wifi_devices: int = 0 + wifi_clients: int = 0 + bluetooth_devices: int = 0 + rf_signals: int = 0 + new_devices: int = 0 + missing_devices: int = 0 # Sweep duration sweep_start: datetime | None = None @@ -194,13 +194,13 @@ def generate_executive_summary(report: TSCMReport) -> str: lines.append("") # Key statistics - lines.append("SCAN STATISTICS:") - lines.append(f" - Total devices scanned: {report.total_devices_scanned}") - lines.append(f" - WiFi access points: {report.wifi_devices}") - lines.append(f" - WiFi clients: {report.wifi_clients}") - lines.append(f" - Bluetooth devices: {report.bluetooth_devices}") - lines.append(f" - RF signals: {report.rf_signals}") - lines.append("") + lines.append("SCAN STATISTICS:") + lines.append(f" - Total devices scanned: {report.total_devices_scanned}") + lines.append(f" - WiFi access points: {report.wifi_devices}") + lines.append(f" - WiFi clients: {report.wifi_clients}") + lines.append(f" - Bluetooth devices: {report.bluetooth_devices}") + lines.append(f" - RF signals: {report.rf_signals}") + lines.append("") # Findings summary lines.append("FINDINGS SUMMARY:") @@ -422,14 +422,14 @@ def generate_technical_annex_json(report: TSCMReport) -> dict: 'capabilities': report.capabilities, 'limitations': report.limitations, - 'statistics': { - 'total_devices': report.total_devices_scanned, - 'wifi_devices': report.wifi_devices, - 'wifi_clients': report.wifi_clients, - 'bluetooth_devices': report.bluetooth_devices, - 'rf_signals': report.rf_signals, - 'new_devices': report.new_devices, - 'missing_devices': report.missing_devices, + 'statistics': { + 'total_devices': report.total_devices_scanned, + 'wifi_devices': report.wifi_devices, + 'wifi_clients': report.wifi_clients, + 'bluetooth_devices': report.bluetooth_devices, + 'rf_signals': report.rf_signals, + 'new_devices': report.new_devices, + 'missing_devices': report.missing_devices, 'high_interest_count': len(report.high_interest_findings), 'needs_review_count': len(report.needs_review_findings), 'informational_count': len(report.informational_findings), @@ -776,23 +776,23 @@ class TSCMReportBuilder: self.report.meeting_summaries.append(meeting) return self - def add_statistics( - self, - wifi: int = 0, - wifi_clients: int = 0, - bluetooth: int = 0, - rf: int = 0, - new: int = 0, - missing: int = 0 - ) -> TSCMReportBuilder: - self.report.wifi_devices = wifi - self.report.wifi_clients = wifi_clients - self.report.bluetooth_devices = bluetooth - self.report.rf_signals = rf - self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf - self.report.new_devices = new - self.report.missing_devices = missing - return self + def add_statistics( + self, + wifi: int = 0, + wifi_clients: int = 0, + bluetooth: int = 0, + rf: int = 0, + new: int = 0, + missing: int = 0 + ) -> TSCMReportBuilder: + self.report.wifi_devices = wifi + self.report.wifi_clients = wifi_clients + self.report.bluetooth_devices = bluetooth + self.report.rf_signals = rf + self.report.total_devices_scanned = wifi + wifi_clients + bluetooth + rf + self.report.new_devices = new + self.report.missing_devices = missing + return self def add_device_timelines(self, timelines: list[dict]) -> TSCMReportBuilder: self.report.device_timelines = timelines @@ -847,6 +847,7 @@ def generate_report( baseline_diff: dict | None = None, meeting_summaries: list[dict] | None = None, correlations: list[dict] | None = None, + categories: list[str] | None = None, ) -> TSCMReport: """ Generate a complete TSCM report from sweep data. @@ -882,34 +883,45 @@ def generate_report( # Capabilities builder.add_capabilities(capabilities) + # Apply category filter before building findings + if categories: + _cat_map = {'needs_review': {'review', 'needs_review'}} + allowed = set() + for c in categories: + allowed |= _cat_map.get(c, {c}) + device_profiles = [ + p for p in device_profiles + if p.get('risk_level', 'informational') in allowed + ] + # Add findings from profiles builder.add_findings_from_profiles(device_profiles) # Statistics - results = sweep_data.get('results', {}) - wifi_count = results.get('wifi_count') - if wifi_count is None: - wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) - - wifi_client_count = results.get('wifi_client_count') - if wifi_client_count is None: - wifi_client_count = len(results.get('wifi_clients', [])) - - bt_count = results.get('bt_count') - if bt_count is None: - bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) + results = sweep_data.get('results', {}) + wifi_count = results.get('wifi_count') + if wifi_count is None: + wifi_count = len(results.get('wifi_devices', results.get('wifi', []))) + + wifi_client_count = results.get('wifi_client_count') + if wifi_client_count is None: + wifi_client_count = len(results.get('wifi_clients', [])) + + bt_count = results.get('bt_count') + if bt_count is None: + bt_count = len(results.get('bt_devices', results.get('bluetooth', []))) rf_count = results.get('rf_count') if rf_count is None: rf_count = len(results.get('rf_signals', results.get('rf', []))) - builder.add_statistics( - wifi=wifi_count, - wifi_clients=wifi_client_count, - bluetooth=bt_count, - rf=rf_count, - new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, - missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0, + builder.add_statistics( + wifi=wifi_count, + wifi_clients=wifi_client_count, + bluetooth=bt_count, + rf=rf_count, + new=baseline_diff.get('summary', {}).get('new_devices', 0) if baseline_diff else 0, + missing=baseline_diff.get('summary', {}).get('missing_devices', 0) if baseline_diff else 0, ) # Technical data