From 065b4778a9c0c6db0d92b7c289127bb0fa0287a4 Mon Sep 17 00:00:00 2001 From: James Smith Date: Sun, 5 Jul 2026 14:37:08 +0100 Subject: [PATCH] feat: TSCM sweep metadata, cleared devices, and examiner ignore list Four new features requested by TSCM users: - Site/Location and Examiner name fields appear at the top of the sweep config; both are embedded in HTML and PDF/annex reports. - Mark Cleared button on every live device item dims the entry with a CLEARED badge and excludes it from generated reports. Cleared state resets at the start of each new sweep. The report executive summary shows a count of cleared devices. - Ignore List stores the examiner's own devices persistently in localStorage. Ignored devices are filtered from the live display and all report exports. An Ignore button appears on every device item; the sidebar Examiner Ignore List section shows current entries with per-item removal and a clear-all button. - Site/examiner params forwarded to PDF and annex server routes so the text report header includes them. Co-Authored-By: Claude Sonnet 4.6 --- routes/tscm/analysis.py | 8 ++ static/css/modes/tscm.css | 39 ++++++ templates/index.html | 191 +++++++++++++++++++++++++---- templates/partials/modes/tscm.html | 18 +++ utils/tscm/reports.py | 15 +++ 5 files changed, 246 insertions(+), 25 deletions(-) diff --git a/routes/tscm/analysis.py b/routes/tscm/analysis.py index 9b09c74..66e5aa0 100644 --- a/routes/tscm/analysis.py +++ b/routes/tscm/analysis.py @@ -281,6 +281,8 @@ def get_pdf_report(): return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 categories = _parse_categories_param(request.args.get('categories', '')) + site_name = request.args.get('site_name', '').strip()[:200] + examiner_name = request.args.get('examiner_name', '').strip()[:200] # Get data for report correlation = get_correlation_engine() @@ -298,6 +300,8 @@ def get_pdf_report(): capabilities=caps, timelines=timelines, categories=categories, + site_name=site_name, + examiner_name=examiner_name, ) pdf_content = get_pdf_report(report) @@ -339,6 +343,8 @@ def get_technical_annex(): return jsonify({'status': 'error', 'message': 'Sweep not found'}), 404 categories = _parse_categories_param(request.args.get('categories', '')) + site_name = request.args.get('site_name', '').strip()[:200] + examiner_name = request.args.get('examiner_name', '').strip()[:200] # Get data for report correlation = get_correlation_engine() @@ -356,6 +362,8 @@ def get_technical_annex(): capabilities=caps, timelines=timelines, categories=categories, + site_name=site_name, + examiner_name=examiner_name, ) if format_type == 'csv': diff --git a/static/css/modes/tscm.css b/static/css/modes/tscm.css index 37da1e5..e02887e 100644 --- a/static/css/modes/tscm.css +++ b/static/css/modes/tscm.css @@ -229,6 +229,45 @@ text-transform: uppercase; letter-spacing: 0.4px; } +.cleared-badge { + margin-left: 6px; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + background: rgba(0, 204, 0, 0.15); + color: #00cc00; + border: 1px solid rgba(0, 204, 0, 0.4); + text-transform: uppercase; + letter-spacing: 0.4px; +} +.tscm-cleared { + opacity: 0.55; +} +.tscm-device-actions { + display: flex; + gap: 4px; + margin-top: 6px; +} +.tscm-action-btn { + font-size: 9px; + padding: 2px 7px; + border: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + border-radius: 3px; + cursor: pointer; + transition: opacity 0.15s; +} +.tscm-action-btn:hover { opacity: 0.75; } +.tscm-action-btn.cleared { + border-color: #00cc00; + color: #00cc00; + background: rgba(0, 204, 0, 0.1); +} +.tscm-action-btn.ignore { + border-color: rgba(255, 107, 107, 0.5); + color: #ff6b6b; +} .tscm-device-header { display: flex; justify-content: space-between; diff --git a/templates/index.html b/templates/index.html index 5997f30..c34f5d0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4915,6 +4915,7 @@ if (mode === 'tscm') { loadTscmBaselines(); refreshTscmDevices(); + updateTscmIgnoreListUI(); } // Initialize Drone mode when selected @@ -12036,6 +12037,13 @@ let tscmWifiDevices = []; let tscmWifiClients = []; let tscmBtDevices = []; + // Devices marked as cleared by investigator (reset each sweep) + let tscmClearedDevices = new Set(); + // Persistent ignore list — examiner's own devices, excluded from display and reports + let tscmIgnoreList = (() => { + try { return new Set(JSON.parse(localStorage.getItem('tscmIgnoreList') || '[]')); } + catch(e) { return new Set(); } + })(); let tscmBaselineComparison = null; let tscmIdentityClusters = []; let tscmIdentitySummary = null; @@ -12286,6 +12294,7 @@ tscmIdentityClusters = []; tscmIdentitySummary = null; tscmHighInterestDevices = []; + tscmClearedDevices = new Set(); updateTscmDisplays(); updateTscmThreatCounts(); @@ -12378,12 +12387,18 @@ const durationMin = Math.floor(durationMs / 60000); const durationSec = Math.floor((durationMs % 60000) / 1000); - // Categorize devices by classification - const allDevices = [ - ...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi' })), - ...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth' })), - ...tscmRfSignals.map(d => ({ ...d, protocol: 'RF' })) + // Read sweep metadata + const siteName = document.getElementById('tscmSiteName')?.value?.trim() || ''; + const examinerName = document.getElementById('tscmExaminerName')?.value?.trim() || ''; + + // Categorize devices by classification — exclude cleared and ignored devices from reports + const allDevicesRaw = [ + ...tscmWifiDevices.map(d => ({ ...d, protocol: 'WiFi', _key: _tscmDeviceKey(d, 'wifi') })), + ...tscmBtDevices.map(d => ({ ...d, protocol: 'Bluetooth', _key: _tscmDeviceKey(d, 'bluetooth') })), + ...tscmRfSignals.map(d => ({ ...d, protocol: 'RF', _key: _tscmDeviceKey(d, 'rf') })) ]; + const clearedCount = allDevicesRaw.filter(d => tscmClearedDevices.has(d._key)).length; + const allDevices = allDevicesRaw.filter(d => !tscmClearedDevices.has(d._key)); 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)); @@ -12753,6 +12768,8 @@
${allDevices.length}
Total Devices
+ ${siteName ? `
${siteName.replace(//g,'>')}
Site / Location
` : ''} + ${examinerName ? `
${examinerName.replace(//g,'>')}
Examiner
` : ''} @@ -12780,6 +12797,7 @@ Assessment: ${assessment} ${categoryNote} + ${clearedCount > 0 ? `
${clearedCount} device(s) marked as cleared by investigator — excluded from this report.
` : ''} ${highInterest.length > 0 ? ` @@ -13131,6 +13149,8 @@ } function addTscmWifiDevice(device) { + // Check ignore list + if (tscmIgnoreList.has(`wifi:${device.bssid}`)) return; // Check if already exists const exists = tscmWifiDevices.some(d => d.bssid === device.bssid); if (!exists) { @@ -13159,6 +13179,7 @@ function addTscmWifiClient(client) { const mac = client.mac || client.address || ''; if (!mac) return; + if (tscmIgnoreList.has(`wifi:${mac}`)) return; const exists = tscmWifiClients.some(d => (d.mac || d.address) === mac); if (!exists) { if (!client.mac) client.mac = mac; @@ -13181,6 +13202,7 @@ function addTscmBtDevice(device) { const mac = device.mac || device.address || ''; + if (tscmIgnoreList.has(`bt:${mac}`)) return; // Check if already exists const exists = tscmBtDevices.some(d => (d.mac || d.address) === mac); if (!exists) { @@ -13212,6 +13234,7 @@ function addTscmRfSignal(signal) { // Clear any error message since we're receiving signals tscmRfStatusMessage = null; + if (tscmIgnoreList.has(`rf:${(signal.frequency || 0).toFixed(3)}`)) return; // Check if already exists (within 0.1 MHz) const exists = tscmRfSignals.some(s => Math.abs(s.frequency - signal.frequency) < 0.1); const powerDbm = signal.power_dbm ?? signal.power ?? signal.level; @@ -13399,6 +13422,83 @@ if (rfPanel) rfPanel.style.display = showRf ? '' : 'none'; } + // --- Ignore list + cleared device helpers --- + + function _tscmDeviceKey(device, protocol) { + if (protocol === 'wifi') return `wifi:${device.bssid || device.mac || 'unknown'}`; + if (protocol === 'bluetooth') return `bt:${device.mac || device.address || 'unknown'}`; + if (protocol === 'rf') return `rf:${(device.frequency || 0).toFixed(3)}`; + return `unknown:${protocol}`; + } + + function tscmMarkCleared(key) { + if (tscmClearedDevices.has(key)) { + tscmClearedDevices.delete(key); + } else { + tscmClearedDevices.add(key); + } + debouncedUpdateTscmDisplays(); + } + + function _saveTscmIgnoreList() { + try { localStorage.setItem('tscmIgnoreList', JSON.stringify([...tscmIgnoreList])); } catch(e) {} + } + + function updateTscmIgnoreListUI() { + const el = document.getElementById('tscmIgnoreListItems'); + if (!el) return; + if (tscmIgnoreList.size === 0) { + el.innerHTML = '
No devices ignored
'; + return; + } + el.innerHTML = [...tscmIgnoreList].map(k => { + const safe = escapeHtml(k); + return `
+ ${safe} + +
`; + }).join(''); + } + + function tscmAddToIgnoreList(key) { + if (!key || key.endsWith(':unknown')) return; + tscmIgnoreList.add(key); + _saveTscmIgnoreList(); + updateTscmIgnoreListUI(); + // Remove from current sweep arrays and re-render + const colon = key.indexOf(':'); + const proto = key.slice(0, colon); + const id = key.slice(colon + 1); + if (proto === 'wifi') { + tscmWifiDevices = tscmWifiDevices.filter(d => d.bssid !== id); + if (typeof tscmWifiClients !== 'undefined') + tscmWifiClients = tscmWifiClients.filter(d => (d.mac || d.address) !== id); + } else if (proto === 'bt') { + tscmBtDevices = tscmBtDevices.filter(d => (d.mac || d.address) !== id); + } else if (proto === 'rf') { + const freq = parseFloat(id); + tscmRfSignals = tscmRfSignals.filter(s => Math.abs(s.frequency - freq) >= 0.001); + } + updateTscmDisplays(); + updateTscmThreatCounts(); + } + + function tscmRemoveFromIgnoreList(key) { + tscmIgnoreList.delete(key); + _saveTscmIgnoreList(); + updateTscmIgnoreListUI(); + } + + function tscmClearIgnoreList() { + if (tscmIgnoreList.size === 0) return; + if (!confirm('Clear all devices from the ignore list?')) return; + tscmIgnoreList.clear(); + _saveTscmIgnoreList(); + updateTscmIgnoreListUI(); + } + + // --- End ignore list helpers --- + function matchesTscmFilters(device, protocol, options = {}) { if (tscmFilters.protocol !== 'all' && protocol !== tscmFilters.protocol) return false; @@ -14712,12 +14812,16 @@ } else { // Sort by score (highest first) const sorted = [...filtered.wifi].sort((a, b) => (b.score || 0) - (a.score || 0)); - wifiList.innerHTML = sorted.map(d => ` -
+ wifiList.innerHTML = sorted.map(d => { + const dkey = _tscmDeviceKey(d, 'wifi'); + const cleared = tscmClearedDevices.has(dkey); + return ` +
${getClassificationIcon(d.classification)} ${escapeHtml(d.ssid || d.bssid || 'Hidden')} + ${cleared ? 'CLEARED' : ''} ${d.known_device ? 'KNOWN' : ''}
${getScoreBadge(d.score)} @@ -14729,8 +14833,12 @@
${d.indicators && d.indicators.length > 0 ? `
${formatIndicators(d.indicators)}
` : ''} ${d.recommended_action && d.recommended_action !== 'monitor' ? `
Action: ${d.recommended_action}
` : ''} -
- `).join(''); +
+ + +
+
`; + }).join(''); } document.getElementById('tscmWifiCount').textContent = filtered.wifi.length; @@ -14740,13 +14848,17 @@ wifiClientList.innerHTML = `
${filtersActive ? 'No WiFi clients match filters' : 'No WiFi clients detected'}
`; } else { const sortedClients = [...filtered.wifi_clients].sort((a, b) => (b.score || 0) - (a.score || 0)); - wifiClientList.innerHTML = sortedClients.map(c => ` -
+ wifiClientList.innerHTML = sortedClients.map(c => { + const ckey = `wifi:${c.mac || c.address}`; + const cleared = tscmClearedDevices.has(ckey); + return ` +
${getClassificationIcon(c.classification)} ${escapeHtml(c.vendor || 'WiFi Client')} CLIENT + ${cleared ? 'CLEARED' : ''} ${c.known_device ? 'KNOWN' : ''}
${getScoreBadge(c.score)} @@ -14758,8 +14870,12 @@
${c.indicators && c.indicators.length > 0 ? `
${formatIndicators(c.indicators)}
` : ''} ${c.recommended_action && c.recommended_action !== 'monitor' ? `
Action: ${c.recommended_action}
` : ''} -
- `).join(''); +
+ + +
+
`; + }).join(''); } document.getElementById('tscmWifiClientCount').textContent = filtered.wifi_clients.length; @@ -14770,14 +14886,18 @@ } else { // Sort by score (highest first) const sorted = [...filtered.bt].sort((a, b) => (b.score || 0) - (a.score || 0)); - btList.innerHTML = sorted.map(d => ` -
+ btList.innerHTML = sorted.map(d => { + const dkey = _tscmDeviceKey(d, 'bluetooth'); + const cleared = tscmClearedDevices.has(dkey); + return ` +
${getClassificationIcon(d.classification)} ${escapeHtml(d.name || 'Unknown')} ${d.is_audio_capable ? 'AUDIO' : ''} ${formatTrackerBadge(d)} + ${cleared ? 'CLEARED' : ''} ${d.known_device ? 'KNOWN' : ''}
${getScoreBadge(d.score)} @@ -14789,8 +14909,12 @@
${d.indicators && d.indicators.length > 0 ? `
${formatIndicators(d.indicators)}
` : ''} ${d.recommended_action && d.recommended_action !== 'monitor' ? `
Action: ${d.recommended_action}
` : ''} -
- `).join(''); +
+ + +
+
`; + }).join(''); } document.getElementById('tscmBtCount').textContent = filtered.bt.length; @@ -14805,12 +14929,16 @@ } else { // Sort by score (highest first) const sorted = [...filtered.rf].sort((a, b) => (b.score || 0) - (a.score || 0)); - rfList.innerHTML = sorted.map(s => ` -
+ rfList.innerHTML = sorted.map(s => { + const skey = _tscmDeviceKey(s, 'rf'); + const cleared = tscmClearedDevices.has(skey); + return ` +
${getClassificationIcon(s.classification)} ${s.frequency.toFixed(3)} MHz + ${cleared ? 'CLEARED' : ''} ${s.known_device ? 'KNOWN' : ''}
${getScoreBadge(s.score)} @@ -14822,8 +14950,12 @@
${s.indicators && s.indicators.length > 0 ? `
${formatIndicators(s.indicators)}
` : ''} ${s.recommended_action && s.recommended_action !== 'monitor' ? `
Action: ${s.recommended_action}
` : ''} -
- `).join(''); +
+ + +
+
`; + }).join(''); } document.getElementById('tscmRfCount').textContent = filtered.rf.length; @@ -16239,10 +16371,19 @@ return cats.length < 3 ? `categories=${cats.join(',')}` : ''; } + function _tscmMetaParams() { + const params = []; + const site = document.getElementById('tscmSiteName')?.value?.trim(); + const examiner = document.getElementById('tscmExaminerName')?.value?.trim(); + if (site) params.push(`site_name=${encodeURIComponent(site)}`); + if (examiner) params.push(`examiner_name=${encodeURIComponent(examiner)}`); + return params.join('&'); + } + async function tscmDownloadPdf() { try { - const catParam = _tscmCategoryParam(); - const response = await fetch(`/tscm/report/pdf${catParam ? '?' + catParam : ''}`); + const parts = [_tscmCategoryParam(), _tscmMetaParams()].filter(Boolean); + const response = await fetch(`/tscm/report/pdf${parts.length ? '?' + parts.join('&') : ''}`); if (response.ok) { const blob = await response.blob(); const url = URL.createObjectURL(blob); @@ -16265,8 +16406,8 @@ async function tscmDownloadAnnex(format) { try { - const catParam = _tscmCategoryParam(); - const response = await fetch(`/tscm/report/annex?format=${format}${catParam ? '&' + catParam : ''}`); + const parts = [_tscmCategoryParam(), _tscmMetaParams()].filter(Boolean); + const response = await fetch(`/tscm/report/annex?format=${format}${parts.length ? '&' + parts.join('&') : ''}`); 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 0084eb0..ecb15e9 100644 --- a/templates/partials/modes/tscm.html +++ b/templates/partials/modes/tscm.html @@ -4,6 +4,16 @@

TSCM Sweep Alpha

+
+ + +
+ +
+ + +
+