diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index b1c893ce..0782c489 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -1209,7 +1209,8 @@ def _auditcsv(rows, columns, filename): AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge', - 'name', 'locker_location'] + 'name', 'locker_location', 'sanitized', 'scanned_viruses'] + @usb_bp.route('/reports/audit', methods=['GET']) @@ -1235,7 +1236,8 @@ def report_audit(): try: with conn.cursor() as cur: sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, ' - 'l.locker_location, d.device_desc, ' + 'l.locker_location, l.sanitized, l.scanned_viruses, ' + 'd.device_desc, ' 'u.first_name, u.last_name ' 'FROM checkinoutlog l ' 'LEFT JOIN devices d ON d.device_id = l.device_id ' @@ -1263,6 +1265,13 @@ def report_audit(): 'badge': row.get('badge_number') or '', 'name': name, 'locker_location': row.get('locker_location') or '', + # Only a return can carry these: the wipe and the scan + # happen when the drive comes back, so a check-out row + # showing them would invent a fact. + 'sanitized': (selfhosted.auditflag(row.get('sanitized')) + if row.get('action') == 'check-in' else ''), + 'scanned_viruses': (selfhosted.auditflag(row.get('scanned_viruses')) + if row.get('action') == 'check-in' else ''), }) finally: conn.close() diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py index ae98668e..5d023592 100644 --- a/plugins/usb/api/selfhosted.py +++ b/plugins/usb/api/selfhosted.py @@ -544,6 +544,22 @@ def _kiosk_flag(key, default): return str(row.value).strip().lower() in ('true', '1', 'yes', 'on') +def auditflag(value): + """Render a stored tri-state as 'yes', 'no', or '' for the report. + + THREE states, not two. Both stores keep these nullable on purpose: a null + means nobody recorded it, which is not the same claim as 'no'. Rendering an + unrecorded return as 'No' would put a assertion in an audit record that + nobody ever made - the opposite of what the record is for. So unknown stays + blank and reads as the gap it is. + """ + if value is None or value == '': + return '' + if isinstance(value, str): + return 'yes' if value.strip() in ('1', 'true', 'True') else 'no' + return 'yes' if value else 'no' + + def audit_rows(days): """Check-out / check-in events over a window (self-hosted mode). @@ -576,11 +592,16 @@ def audit_rows(days): 'locker_location': device.storagelocation or '', } if checkout.checkouttime and (cutoff is None or checkout.checkouttime >= cutoff): + # No wipe or scan state on a take: both are recorded on the return, + # so putting them here would invent a fact. rows.append(dict(base, timestamp=checkout.checkouttime.isoformat(), - action='check-out')) + action='check-out', + sanitized='', scanned_viruses='')) if checkout.checkintime and (cutoff is None or checkout.checkintime >= cutoff): rows.append(dict(base, timestamp=checkout.checkintime.isoformat(), - action='check-in')) + action='check-in', + sanitized=auditflag(checkout.waswiped), + scanned_viruses=auditflag(checkout.scannedviruses))) # Newest first, and a total order so two events in the same second do not # swap places between identical runs. diff --git a/tests/test_plugins/test_usb_reports.py b/tests/test_plugins/test_usb_reports.py index cfd5284c..15f9c250 100644 --- a/tests/test_plugins/test_usb_reports.py +++ b/tests/test_plugins/test_usb_reports.py @@ -137,3 +137,66 @@ def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata assert report['id'] == 'usb-audit' # The endpoint is what the reports page fetches; a wrong one renders blank. assert report['endpoint'] == '/api/usb/reports/audit' + + +# -- sanitized / virus-scan state in the audit --------------------------------- +# CMMC evidence: the report answered who had which drive and when, but not +# whether it was wiped or scanned on the way back - which is the question the +# record exists to answer. Both stores held the values; neither reported them. + +def test_audit_reports_wipe_and_scan_state_on_returns(db, client, auth_headers): + from plugins.usb.models import USBDevice, USBCheckout + from shopdb.core.models import Setting + from datetime import datetime, timedelta + + db.session.add(Setting(key='usb_directory_mode', value='selfhosted', + valuetype='string', category='site')) + device = USBDevice(serialnumber='USB-9', label='Grey SanDisk') + db.session.add(device) + db.session.flush() + now = datetime.utcnow() + db.session.add(USBCheckout( + usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana', + checkouttime=now - timedelta(hours=2), checkintime=now - timedelta(hours=1), + waswiped=True, scannedviruses=False)) + db.session.commit() + + response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers) + assert response.status_code == 200 + data = response.get_json()['data'] + assert 'sanitized' in data['columns'] + assert 'scanned_viruses' in data['columns'] + + checkin = [r for r in data['rows'] if r['action'] == 'check-in'][0] + checkout = [r for r in data['rows'] if r['action'] == 'check-out'][0] + assert checkin['sanitized'] == 'yes' + assert checkin['scanned_viruses'] == 'no' + # A take carries neither: both are recorded when the drive comes back. + assert checkout['sanitized'] == '' + assert checkout['scanned_viruses'] == '' + + +def test_unrecorded_wipe_is_blank_not_no(db, client, auth_headers): + """A null means nobody wrote it down. Rendering that as 'no' would assert + something no one ever claimed, in the one record kept to be trustworthy.""" + from plugins.usb.models import USBDevice, USBCheckout + from shopdb.core.models import Setting + from datetime import datetime, timedelta + + db.session.add(Setting(key='usb_directory_mode', value='selfhosted', + valuetype='string', category='site')) + device = USBDevice(serialnumber='USB-10', label='Old stock') + db.session.add(device) + db.session.flush() + now = datetime.utcnow() + db.session.add(USBCheckout( + usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana', + checkouttime=now - timedelta(hours=3), checkintime=now - timedelta(hours=2), + waswiped=None, scannedviruses=None)) + db.session.commit() + + response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers) + checkin = [r for r in response.get_json()['data']['rows'] + if r['action'] == 'check-in'][0] + assert checkin['sanitized'] == '' + assert checkin['scanned_viruses'] == ''