Repository navigation
Expand file tree
/
Copy pathdb_manager.py
More file actions
executable file
·2872 lines (2453 loc) · 118 KB
/
Copy pathdb_manager.py
File metadata and controls
executable file
·2872 lines (2453 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# db_manager.py
# SQLite Database Manager for Ragnar - Replaces netkb.csv
#
# ARCHITECTURE:
# ============
# This module provides the single source of truth for all host/network data in Ragnar.
# It replaces the CSV-based netkb.csv with a robust SQLite database that supports:
# - Thread-safe concurrent read/write operations
# - Complex queries and filtering
# - Efficient indexing for fast lookups
# - Data integrity with foreign keys and constraints
# - Automatic migration from legacy CSV files
#
# DATA LIFECYCLE:
# ==============
# 1. Host Discovery: ARP scan, ping sweep, nmap → insert/update hosts
# 2. Port Scanning: Nmap port scans → update ports column
# 3. Vulnerability Scanning: Nmap vuln scanner → update vulnerabilities column
# 4. Attack Execution: Various actions → update action status columns
# 5. Ping Tracking: Continuous monitoring → update failed_ping_count, status
# 6. Cleanup: Remove hosts with last_seen > 24 hours ago
#
# STATUS STATES:
# =============
# - 'alive': Host responding to pings (failed_ping_count < 30)
# - 'degraded': Host failed 30 consecutive pings but seen within 24h
# - 'dead': Host not seen for 24+ hours (auto-deleted)
#
# MIGRATION:
# =========
# On first run, automatically migrates data from netkb.csv to SQLite.
# CSV file is kept for backward compatibility but becomes read-only.
import os
import sys
import sqlite3
import json
import csv
import logging
import threading
import re
import unicodedata
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from contextlib import contextmanager
# Add parent directory to path for imports
parent_dir = os.path.dirname(os.path.abspath(__file__))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from logger import Logger
logger = Logger(name="db_manager.py", level=logging.INFO)
class DatabaseManager:
"""
Thread-safe SQLite database manager for Ragnar host/network data.
ACTION_STATUS_COLUMNS = {
'ssh_connector',
'rdp_connector',
'ftp_connector',
'smb_connector',
'telnet_connector',
'sql_connector',
'steal_files_ssh',
'steal_files_rdp',
'steal_files_ftp',
'steal_files_smb',
'steal_files_telnet',
'steal_data_sql',
'nmap_vuln_scanner',
'scanner_status'
}
This class handles all database operations including:
- Schema creation and migrations
- CRUD operations for hosts
- Ping failure tracking
- Status management (alive/degraded/dead)
- CSV migration and backward compatibility
"""
def __init__(self, db_path: str = None, currentdir: str = None, data_root: str = None):
"""
Initialize the database manager.
Args:
db_path: Path to SQLite database file (default: data/ragnar.db)
currentdir: Root directory of Ragnar installation
"""
self.currentdir = currentdir or os.path.dirname(os.path.abspath(__file__))
self.datadir = data_root or os.path.join(self.currentdir, 'data')
# Database file location
if db_path is None:
db_path = os.path.join(self.datadir, 'ragnar.db')
self.db_path = db_path
self.lock = threading.RLock() # Reentrant lock for nested calls
# Legacy CSV paths for migration
self.netkb_csv = os.path.join(self.datadir, 'netkb.csv')
# Initialize database
self._init_database()
logger.info(f"DatabaseManager initialized: {self.db_path}")
def configure_storage(self, data_root: Optional[str], db_path: Optional[str]):
"""Update the storage root and database path when network context changes."""
updated = False
with self.lock:
if data_root and data_root != self.datadir:
self.datadir = data_root
updated = True
if db_path and db_path != self.db_path:
self.db_path = db_path
updated = True
self.netkb_csv = os.path.join(self.datadir, 'netkb.csv')
if updated:
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
if updated:
self._init_database()
logger.info(f"Database storage configured: root={self.datadir}, db={self.db_path}")
@contextmanager
def get_connection(self):
"""
Context manager for database connections.
Ensures thread-safe access and automatic cleanup.
Usage:
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM hosts")
"""
conn = None
try:
with self.lock:
# timeout: wait up to 30s for a lock instead of erroring instantly.
conn = sqlite3.connect(self.db_path, check_same_thread=False,
timeout=30)
conn.row_factory = sqlite3.Row # Enable dict-like access
# WAL lets readers (the dashboard) run concurrently with the
# writer (the scanner) instead of blocking — this is what fixes
# the recurring "database is locked" on /api/dashboard/stats.
# WAL is persisted in the DB header, so this converts the file
# once and stays; setting it per-connection is a cheap no-op.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
conn.execute("PRAGMA synchronous=NORMAL") # safe under WAL, less fsync
conn.execute("PRAGMA foreign_keys = ON") # Enable foreign keys
yield conn
conn.commit()
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Database error: {e}")
raise
finally:
if conn:
conn.close()
def _init_database(self):
"""
Initialize database schema and perform migrations.
Creates tables if they don't exist and migrates CSV data if needed.
"""
with self.get_connection() as conn:
cursor = conn.cursor()
# Create hosts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS hosts (
mac TEXT PRIMARY KEY,
ip TEXT,
hostname TEXT,
vendor TEXT,
ports TEXT,
services TEXT,
vulnerabilities TEXT,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_ping_success TIMESTAMP,
failed_ping_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'alive',
alive_count INTEGER DEFAULT 0,
network_profile TEXT,
scanner_status TEXT,
ssh_connector TEXT,
rdp_connector TEXT,
ftp_connector TEXT,
smb_connector TEXT,
telnet_connector TEXT,
sql_connector TEXT,
steal_files_ssh TEXT,
steal_files_rdp TEXT,
steal_files_ftp TEXT,
steal_files_smb TEXT,
steal_files_telnet TEXT,
steal_data_sql TEXT,
nmap_vuln_scanner TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes for fast lookups
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_hosts_ip ON hosts(ip)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_hosts_status ON hosts(status)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_hosts_last_seen ON hosts(last_seen)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_hosts_mac ON hosts(mac)
""")
# Create scan_history table for audit trail
cursor.execute("""
CREATE TABLE IF NOT EXISTS scan_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT,
ip TEXT,
scan_type TEXT,
ports_found TEXT,
vulnerabilities_found INTEGER DEFAULT 0,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (mac) REFERENCES hosts(mac) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_history_mac ON scan_history(mac)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_history_timestamp ON scan_history(timestamp)
""")
# Create WiFi scan cache table for optimized network scanning
cursor.execute("""
CREATE TABLE IF NOT EXISTS wifi_scan_cache (
ssid TEXT PRIMARY KEY,
signal INTEGER,
security TEXT,
frequency TEXT,
channel INTEGER,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
scan_count INTEGER DEFAULT 1,
is_known BOOLEAN DEFAULT 0,
has_system_profile BOOLEAN DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_scan_cache_last_seen ON wifi_scan_cache(last_seen)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_scan_cache_signal ON wifi_scan_cache(signal)
""")
# Create WiFi connection history table for tracking all attempts
cursor.execute("""
CREATE TABLE IF NOT EXISTS wifi_connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ssid TEXT NOT NULL,
connection_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
disconnection_time TIMESTAMP,
success BOOLEAN NOT NULL,
failure_reason TEXT,
signal_strength INTEGER,
duration_seconds INTEGER,
was_auto_connect BOOLEAN DEFAULT 0,
network_profile_existed BOOLEAN DEFAULT 0,
from_ap_mode BOOLEAN DEFAULT 0
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_conn_history_ssid ON wifi_connection_history(ssid)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_conn_history_time ON wifi_connection_history(connection_time)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_conn_history_success ON wifi_connection_history(success)
""")
# Create WiFi network analytics table for performance metrics
cursor.execute("""
CREATE TABLE IF NOT EXISTS wifi_network_analytics (
ssid TEXT PRIMARY KEY,
total_connections INTEGER DEFAULT 0,
successful_connections INTEGER DEFAULT 0,
failed_connections INTEGER DEFAULT 0,
total_connection_time_seconds INTEGER DEFAULT 0,
average_signal INTEGER,
min_signal INTEGER,
max_signal INTEGER,
last_connection_attempt TIMESTAMP,
last_successful_connection TIMESTAMP,
last_failure_reason TEXT,
success_rate REAL DEFAULT 0.0,
average_connection_duration REAL DEFAULT 0.0,
priority_score REAL DEFAULT 50.0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_analytics_success_rate ON wifi_network_analytics(success_rate)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_wifi_analytics_priority ON wifi_network_analytics(priority_score)
""")
# ================================================================
# Advanced Vulnerability Scanner Tables (for scan persistence)
# ================================================================
# Create scan_jobs table for persisting scan state
cursor.execute("""
CREATE TABLE IF NOT EXISTS scan_jobs (
scan_id TEXT PRIMARY KEY,
scan_type TEXT NOT NULL,
target TEXT NOT NULL,
status TEXT DEFAULT 'pending',
progress_percent INTEGER DEFAULT 0,
findings_count INTEGER DEFAULT 0,
current_check TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
error_message TEXT,
options TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_jobs_status ON scan_jobs(status)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_jobs_target ON scan_jobs(target)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_jobs_created ON scan_jobs(created_at)
""")
# Create scan_findings table for persisting vulnerability findings
cursor.execute("""
CREATE TABLE IF NOT EXISTS scan_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
finding_id TEXT UNIQUE NOT NULL,
scan_id TEXT NOT NULL,
scanner TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER,
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
cve_ids TEXT,
cwe_ids TEXT,
cvss_score REAL,
evidence TEXT,
remediation TEXT,
reference_urls TEXT,
tags TEXT,
matched_at TEXT,
template_id TEXT,
raw_output TEXT,
details TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (scan_id) REFERENCES scan_jobs(scan_id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_findings_scan_id ON scan_findings(scan_id)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_findings_host ON scan_findings(host)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_findings_severity ON scan_findings(severity)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_scan_findings_scanner ON scan_findings(scanner)
""")
# ================================================================
# ZAP Target Credentials Table (for persistent auth per target)
# ================================================================
# Create zap_target_credentials table for storing auth per target
cursor.execute("""
CREATE TABLE IF NOT EXISTS zap_target_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_host TEXT NOT NULL UNIQUE,
auth_type TEXT NOT NULL,
login_url TEXT,
username TEXT,
password_encrypted TEXT,
login_request_data TEXT,
username_field TEXT DEFAULT 'username',
password_field TEXT DEFAULT 'password',
http_realm TEXT,
notes TEXT,
bearer_token_encrypted TEXT,
api_key_encrypted TEXT,
api_key_header TEXT DEFAULT 'X-API-Key',
cookie_value_encrypted TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_zap_creds_target ON zap_target_credentials(target_host)
""")
# Migrate: Add new auth columns if they don't exist (for existing databases)
try:
cursor.execute("SELECT bearer_token_encrypted FROM zap_target_credentials LIMIT 1")
except Exception:
cursor.execute("ALTER TABLE zap_target_credentials ADD COLUMN bearer_token_encrypted TEXT")
cursor.execute("ALTER TABLE zap_target_credentials ADD COLUMN api_key_encrypted TEXT")
cursor.execute("ALTER TABLE zap_target_credentials ADD COLUMN api_key_header TEXT DEFAULT 'X-API-Key'")
cursor.execute("ALTER TABLE zap_target_credentials ADD COLUMN cookie_value_encrypted TEXT")
logger.info("Migrated zap_target_credentials table with new auth columns")
conn.commit()
logger.info("Database schema initialized successfully (includes WiFi, Scan, and ZAP Credentials tables)")
# Perform CSV migration if needed (non-fatal if it fails)
try:
self._migrate_from_csv()
except Exception as e:
logger.warning(f"CSV migration skipped: {e}")
# Clean up any duplicate entries (non-fatal if it fails)
try:
self.cleanup_duplicate_hosts()
except Exception as e:
logger.warning(f"Duplicate cleanup skipped: {e}")
# Ensure legacy hostnames are cleaned up once on startup (non-fatal)
try:
self.sanitize_all_hostnames()
except Exception as e:
logger.warning(f"Hostname sanitization skipped: {e}")
def _migrate_from_csv(self):
"""
Migrate data from legacy netkb.csv to SQLite database.
Only runs if CSV exists and database is empty.
"""
if not os.path.exists(self.netkb_csv):
logger.debug("No netkb.csv found - skipping migration")
return
# Check if database already has data
with self.get_connection() as conn:
cursor = conn.cursor()
# Verify hosts table exists before querying
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='hosts'")
if not cursor.fetchone():
logger.warning("hosts table not found - skipping CSV migration")
return
cursor.execute("SELECT COUNT(*) FROM hosts")
count = cursor.fetchone()[0]
if count > 0:
logger.debug(f"Database already contains {count} hosts - skipping CSV migration")
return
logger.info(f"Migrating data from {self.netkb_csv} to SQLite...")
try:
with open(self.netkb_csv, 'r', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
migrated_count = 0
for row in reader:
try:
mac = row.get('MAC', '').strip()
if not mac or mac.upper() == 'UNKNOWN':
continue
# Convert CSV row to database format
host_data = {
'mac': mac,
'ip': row.get('IP', '').strip(),
'hostname': row.get('Hostname', '').strip(),
'vendor': row.get('Vendor', '').strip(),
'ports': row.get('Ports', '').strip(),
'services': row.get('Services', '').strip() or row.get('Service', '').strip(),
'vulnerabilities': row.get('Nmap Vulnerabilities', '').strip(),
'alive_count': self._safe_int(row.get('Alive Count', 0)),
'network_profile': row.get('Network Profile', '').strip(),
'scanner_status': row.get('Scanner', '').strip(),
'ssh_connector': row.get('ssh_connector', '').strip(),
'rdp_connector': row.get('rdp_connector', '').strip(),
'ftp_connector': row.get('ftp_connector', '').strip(),
'smb_connector': row.get('smb_connector', '').strip(),
'telnet_connector': row.get('telnet_connector', '').strip(),
'sql_connector': row.get('sql_connector', '').strip(),
'steal_files_ssh': row.get('steal_files_ssh', '').strip(),
'steal_files_rdp': row.get('steal_files_rdp', '').strip(),
'steal_files_ftp': row.get('steal_files_ftp', '').strip(),
'steal_files_smb': row.get('steal_files_smb', '').strip(),
'steal_files_telnet': row.get('steal_files_telnet', '').strip(),
'steal_data_sql': row.get('steal_data_sql', '').strip(),
'nmap_vuln_scanner': row.get('nmap_vuln_scanner', '').strip(),
'notes': row.get('Notes', '').strip(),
}
self.upsert_host(**host_data)
migrated_count += 1
except Exception as e:
logger.warning(f"Failed to migrate row for MAC {mac}: {e}")
continue
logger.info(f"✅ Successfully migrated {migrated_count} hosts from CSV to SQLite")
# Backup CSV after successful migration
backup_path = self.netkb_csv + '.migrated_backup'
if not os.path.exists(backup_path):
import shutil
shutil.copy2(self.netkb_csv, backup_path)
logger.info(f"CSV backed up to: {backup_path}")
except Exception as e:
logger.error(f"CSV migration failed: {e}")
def _safe_int(self, value, default=0):
"""Safely convert value to integer."""
try:
return int(value) if value else default
except (ValueError, TypeError):
return default
def _is_pseudo_mac(self, mac: str) -> bool:
"""Check if MAC is a pseudo-MAC (format: 00:00:c0:a8:xx:xx or similar)."""
if not mac:
return False
return mac.lower().startswith('00:00:')
def sanitize_hostname(self, hostname: Optional[str]) -> str:
"""Normalize hostnames by removing control chars, collapsing duplicates, and limiting length."""
if hostname is None:
return ''
# Normalize unicode and drop non-printable characters
normalized = unicodedata.normalize('NFKC', str(hostname))
normalized = ''.join(ch for ch in normalized if ch.isprintable())
normalized = normalized.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ')
# Split on known separators and collapse whitespace
raw_tokens = re.split(r'[;,\|]+', normalized)
cleaned_tokens = []
seen = set()
for token in raw_tokens:
token = re.sub(r'\s+', ' ', token).strip(" _-.")
if not token:
continue
token_key = token.lower()
if token_key in seen:
continue
seen.add(token_key)
cleaned_tokens.append(token)
if len(cleaned_tokens) >= 4:
break # Prevent very long alias lists
sanitized = ' / '.join(cleaned_tokens)
if not sanitized:
return ''
# Enforce max length to keep DB rows tidy
return sanitized[:128]
def sanitize_all_hostnames(self) -> int:
"""Retroactively sanitize hostnames already stored in the database."""
try:
updated = 0
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT mac, hostname FROM hosts")
rows = cursor.fetchall()
now = datetime.now().isoformat()
for row in rows:
mac = row['mac'] if isinstance(row, sqlite3.Row) else row[0]
current_hostname = row['hostname'] if isinstance(row, sqlite3.Row) else row[1]
sanitized = self.sanitize_hostname(current_hostname)
if sanitized != (current_hostname or ''):
cursor.execute(
"UPDATE hosts SET hostname = ?, updated_at = ? WHERE mac = ?",
(sanitized, now, mac)
)
updated += 1
if updated:
logger.info(f"Sanitized {updated} hostnames with invalid formatting")
return updated
except Exception as e:
logger.error(f"Failed to sanitize existing hostnames: {e}")
return 0
def upsert_host(self, mac: str, ip: str = None, hostname: str = None,
vendor: str = None, ports: str = None, services: str = None,
vulnerabilities: str = None, **kwargs):
"""
Insert or update a host record.
DUPLICATE PREVENTION:
- If adding a real MAC for an IP that has a pseudo-MAC, migrates data and deletes pseudo-MAC
- If adding a pseudo-MAC for an IP that has a real MAC, uses the real MAC instead
- Prevents duplicate entries for the same IP with different MACs
Args:
mac: MAC address (primary key)
ip: IP address
hostname: Hostname
vendor: Vendor/manufacturer
ports: Comma-separated list of open ports
services: JSON string or dict of port->service mappings
vulnerabilities: JSON string or dict of vulnerabilities
**kwargs: Additional columns (action statuses, notes, etc.)
Returns:
bool: True if successful
"""
if not mac:
logger.warning("Cannot upsert host without MAC address")
return False
# Normalize MAC address
mac = mac.lower().strip()
# DUPLICATE PREVENTION: Check for existing entry with same IP but different MAC
if ip:
existing_host = self.get_host_by_ip(ip)
if existing_host and existing_host['mac'] != mac:
existing_mac = existing_host['mac']
is_new_mac_pseudo = self._is_pseudo_mac(mac)
is_existing_mac_pseudo = self._is_pseudo_mac(existing_mac)
if is_new_mac_pseudo and not is_existing_mac_pseudo:
# Trying to add pseudo-MAC when real MAC exists - use real MAC instead
logger.info(f"🔄 Real MAC {existing_mac} already exists for IP {ip}, ignoring pseudo-MAC {mac}")
mac = existing_mac
elif not is_new_mac_pseudo and is_existing_mac_pseudo:
# Upgrading from pseudo-MAC to real MAC - migrate data
logger.info(f"🔄 Upgrading IP {ip} from pseudo-MAC {existing_mac} to real MAC {mac}")
# Merge data from old entry with new data, preserving valuable info
# Ports: merge instead of replace to preserve scan history
existing_ports = set(existing_host.get('ports', '').split(',')) if existing_host.get('ports') else set()
new_ports = set(ports.split(',')) if ports else set()
merged_ports = ','.join(sorted(existing_ports.union(new_ports), key=lambda x: int(x) if x.isdigit() else 0))
# Use new data if provided, otherwise keep existing
hostname = hostname or existing_host.get('hostname', '')
vendor = vendor or existing_host.get('vendor', '')
ports = merged_ports
services = services or existing_host.get('services', '')
vulnerabilities = vulnerabilities or existing_host.get('vulnerabilities', '')
# Preserve action statuses and other metadata from pseudo-MAC entry
for field in ['alive_count', 'network_profile', 'scanner_status',
'ssh_connector', 'rdp_connector', 'ftp_connector',
'smb_connector', 'telnet_connector', 'sql_connector',
'steal_files_ssh', 'steal_files_rdp', 'steal_files_ftp',
'steal_files_smb', 'steal_files_telnet', 'steal_data_sql',
'nmap_vuln_scanner', 'notes', 'failed_ping_count']:
if field not in kwargs and existing_host.get(field):
kwargs[field] = existing_host[field]
# Delete the old pseudo-MAC entry
self.delete_host(existing_mac)
logger.info(f"🗑️ Deleted old pseudo-MAC entry {existing_mac}")
elif is_new_mac_pseudo and is_existing_mac_pseudo:
# Both are pseudo-MACs - keep the existing one
logger.debug(f"Both MACs are pseudo for IP {ip}, keeping existing {existing_mac}")
mac = existing_mac
else:
# Both are real MACs but different - this is IP reassignment
logger.warning(f"⚠️ IP {ip} reassigned from MAC {existing_mac} to {mac} (both real MACs)")
# Continue with the new MAC, existing entry will be marked as failed ping
try:
with self.get_connection() as conn:
cursor = conn.cursor()
# Check if host exists
cursor.execute("SELECT mac FROM hosts WHERE mac = ?", (mac,))
exists = cursor.fetchone() is not None
# Prepare data
now = datetime.now().isoformat()
if exists:
# Update existing host
update_fields = []
update_values = []
if ip is not None:
update_fields.append("ip = ?")
update_values.append(ip)
if hostname is not None:
cleaned_hostname = self.sanitize_hostname(hostname)
update_fields.append("hostname = ?")
update_values.append(cleaned_hostname)
if vendor is not None:
update_fields.append("vendor = ?")
update_values.append(vendor)
if ports is not None:
update_fields.append("ports = ?")
update_values.append(ports)
if services is not None:
if isinstance(services, dict):
services = json.dumps(services)
update_fields.append("services = ?")
update_values.append(services)
if vulnerabilities is not None:
if isinstance(vulnerabilities, dict):
vulnerabilities = json.dumps(vulnerabilities)
update_fields.append("vulnerabilities = ?")
update_values.append(vulnerabilities)
# Handle additional kwargs
for key, value in kwargs.items():
if key in ['alive_count', 'network_profile', 'scanner_status',
'ssh_connector', 'rdp_connector', 'ftp_connector',
'smb_connector', 'telnet_connector', 'sql_connector',
'steal_files_ssh', 'steal_files_rdp', 'steal_files_ftp',
'steal_files_smb', 'steal_files_telnet', 'steal_data_sql',
'nmap_vuln_scanner', 'notes', 'status', 'failed_ping_count']:
update_fields.append(f"{key} = ?")
update_values.append(value)
# Always update last_seen and updated_at
update_fields.append("last_seen = ?")
update_values.append(now)
update_fields.append("updated_at = ?")
update_values.append(now)
# Add MAC to end of values for WHERE clause
update_values.append(mac)
if update_fields:
sql = f"UPDATE hosts SET {', '.join(update_fields)} WHERE mac = ?"
cursor.execute(sql, update_values)
logger.debug(f"Updated host: {mac} ({ip})")
else:
# Insert new host
insert_data = {
'mac': mac,
'ip': ip or '',
'hostname': self.sanitize_hostname(hostname) if hostname is not None else '',
'vendor': vendor or '',
'ports': ports or '',
'services': json.dumps(services) if isinstance(services, dict) else (services or ''),
'vulnerabilities': json.dumps(vulnerabilities) if isinstance(vulnerabilities, dict) else (vulnerabilities or ''),
'first_seen': now,
'last_seen': now,
'last_ping_success': now,
'failed_ping_count': 0,
'status': 'alive',
'alive_count': kwargs.get('alive_count', 0),
'network_profile': kwargs.get('network_profile', ''),
'scanner_status': kwargs.get('scanner_status', ''),
'ssh_connector': kwargs.get('ssh_connector', ''),
'rdp_connector': kwargs.get('rdp_connector', ''),
'ftp_connector': kwargs.get('ftp_connector', ''),
'smb_connector': kwargs.get('smb_connector', ''),
'telnet_connector': kwargs.get('telnet_connector', ''),
'sql_connector': kwargs.get('sql_connector', ''),
'steal_files_ssh': kwargs.get('steal_files_ssh', ''),
'steal_files_rdp': kwargs.get('steal_files_rdp', ''),
'steal_files_ftp': kwargs.get('steal_files_ftp', ''),
'steal_files_smb': kwargs.get('steal_files_smb', ''),
'steal_files_telnet': kwargs.get('steal_files_telnet', ''),
'steal_data_sql': kwargs.get('steal_data_sql', ''),
'nmap_vuln_scanner': kwargs.get('nmap_vuln_scanner', ''),
'notes': kwargs.get('notes', ''),
}
columns = ', '.join(insert_data.keys())
placeholders = ', '.join(['?' for _ in insert_data])
sql = f"INSERT INTO hosts ({columns}) VALUES ({placeholders})"
cursor.execute(sql, list(insert_data.values()))
logger.info(f"Inserted new host: {mac} ({ip})")
conn.commit()
return True
except Exception as e:
logger.error(f"Failed to upsert host {mac}: {e}")
return False
def _normalize_action_column(self, action_name: Optional[str]) -> Optional[str]:
"""Map user-facing action names to database columns."""
if not action_name:
return None
normalized = action_name.strip().lower()
alias_map = {
'nmapvulnscanner': 'nmap_vuln_scanner',
'nmap_vulnscanner': 'nmap_vuln_scanner',
'nmap_vuln_scanner': 'nmap_vuln_scanner',
'scanner': 'scanner_status'
}
if normalized in alias_map:
return alias_map[normalized]
if normalized in self.ACTION_STATUS_COLUMNS:
return normalized
lower_columns = {col.lower(): col for col in self.ACTION_STATUS_COLUMNS}
return lower_columns.get(normalized)
def update_host_action_status(self, mac: str, action_name: str, status: str) -> bool:
"""Update a host action status column in the database."""
if not mac:
logger.warning("Cannot update action status without MAC address")
return False
column = self._normalize_action_column(action_name)
if not column:
logger.warning(f"Unknown action column for '{action_name}'")
return False
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"UPDATE hosts SET {column} = ?, updated_at = CURRENT_TIMESTAMP WHERE lower(mac) = lower(?)",
(status, mac)
)
updated = cursor.rowcount > 0
if not updated:
logger.debug(f"No host updated for MAC {mac} when setting {column}")
return updated
except Exception as exc:
logger.error(f"Failed to update action status for {mac}: {exc}")
return False
def delete_host(self, mac: str) -> bool:
"""
Delete a host record by MAC address.
Args:
mac: MAC address to delete
Returns:
bool: True if successful
"""
if not mac:
logger.warning("Cannot delete host without MAC address")
return False
# Normalize MAC address
mac = mac.lower().strip()
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM hosts WHERE mac = ?", (mac,))
conn.commit()
if cursor.rowcount > 0:
logger.info(f"Deleted host: {mac}")
return True
else:
logger.debug(f"No host found to delete: {mac}")
return False
except Exception as e:
logger.error(f"Failed to delete host {mac}: {e}")
return False
def get_host_by_mac(self, mac: str) -> Optional[Dict]:
"""Get host record by MAC address."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM hosts WHERE mac = ?", (mac.lower().strip(),))
row = cursor.fetchone()
if row:
return dict(row)
return None
except Exception as e:
logger.error(f"Failed to get host by MAC {mac}: {e}")
return None
def get_host_by_ip(self, ip: str) -> Optional[Dict]:
"""Get host record by IP address. If multiple exist, prefers real MAC over pseudo-MAC."""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM hosts WHERE ip = ? ORDER BY mac", (ip.strip(),))
rows = cursor.fetchall()
if not rows:
return None
# If multiple entries exist for same IP, prefer real MAC over pseudo-MAC
if len(rows) > 1:
logger.warning(f"Found {len(rows)} entries for IP {ip} - preferring real MAC")
for row in rows:
if not self._is_pseudo_mac(row['mac']):
return dict(row)
return dict(rows[0])
except Exception as e:
logger.error(f"Failed to get host by IP {ip}: {e}")
return None
def get_all_hosts(self, status: str = None) -> List[Dict]:
"""
Get all hosts, optionally filtered by status.
Args:
status: Filter by status ('alive', 'degraded', None for all)
Returns:
List of host dictionaries
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
if status:
cursor.execute("SELECT * FROM hosts WHERE status = ? ORDER BY ip", (status,))
else:
cursor.execute("SELECT * FROM hosts ORDER BY ip")
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Failed to get all hosts: {e}")
return []
def update_ping_status(self, mac: str, success: bool):
"""
Update ping tracking for a host.
Args:
mac: MAC address
success: True if ping succeeded, False if failed
This implements the ping failure tracking logic:
- Success: Reset failed_ping_count to 0, update last_ping_success, status='alive'
- Failure: Increment failed_ping_count, check if >= 30 → status='degraded'
"""
try:
with self.get_connection() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
if success:
# Ping succeeded - reset failure count and mark alive
cursor.execute("""
UPDATE hosts
SET failed_ping_count = 0,
last_ping_success = ?,
last_seen = ?,
status = 'alive',
updated_at = ?
WHERE mac = ?
""", (now, now, now, mac.lower().strip()))
logger.debug(f"Ping success: {mac} - status=alive")
else:
# Ping failed - increment failure count
cursor.execute("""
UPDATE hosts
SET failed_ping_count = failed_ping_count + 1,
updated_at = ?
WHERE mac = ?
""", (now, mac.lower().strip()))