souleyez 2.27.0__py3-none-any.whl → 2.32.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of souleyez might be problematic. Click here for more details.

@@ -31,7 +31,7 @@ class Database:
31
31
  os.makedirs(db_dir, exist_ok=True)
32
32
 
33
33
  conn = sqlite3.connect(self.db_path, timeout=30.0)
34
-
34
+
35
35
  # Set secure permissions (owner read/write only)
36
36
  os.chmod(self.db_path, 0o600)
37
37
  conn.row_factory = sqlite3.Row
@@ -46,7 +46,44 @@ class Database:
46
46
  "foreign_keys": True
47
47
  })
48
48
 
49
+ # Check if this is an existing database (has tables)
50
+ cursor = conn.execute(
51
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='engagements'"
52
+ )
53
+ is_existing_db = cursor.fetchone() is not None
54
+ conn.close()
55
+
56
+ # For EXISTING databases: Run migrations FIRST
57
+ # This ensures new columns exist before schema.sql tries to create indexes on them
58
+ if is_existing_db:
59
+ try:
60
+ from .migrations.migration_manager import MigrationManager
61
+ manager = MigrationManager(self.db_path)
62
+ pending = manager.get_pending_migrations()
63
+ if pending:
64
+ logger.info("Running pending migrations for existing database", extra={
65
+ "pending_count": len(pending)
66
+ })
67
+ manager.migrate()
68
+ logger.info("Migrations completed successfully", extra={
69
+ "count": len(pending)
70
+ })
71
+ except Exception as migration_error:
72
+ logger.error("Failed to run migrations on existing database", extra={
73
+ "error": str(migration_error),
74
+ "error_type": type(migration_error).__name__,
75
+ "traceback": traceback.format_exc()
76
+ })
77
+ raise # Don't continue if migrations fail for existing DB
78
+
79
+ # Reconnect for schema loading
80
+ conn = sqlite3.connect(self.db_path, timeout=30.0)
81
+ conn.row_factory = sqlite3.Row
82
+ conn.execute("PRAGMA foreign_keys = ON")
83
+
49
84
  # Load and execute schema from the same directory as this file
85
+ # For fresh DBs: Creates all tables with current schema
86
+ # For existing DBs: CREATE TABLE IF NOT EXISTS is no-op, but ensures new tables/indexes
50
87
  schema_path = Path(__file__).parent / "schema.sql"
51
88
 
52
89
  if schema_path.exists():
@@ -228,26 +265,28 @@ class Database:
228
265
 
229
266
  conn.commit()
230
267
  conn.close()
231
-
232
- # Run pending migrations after schema.sql loads
233
- try:
234
- from .migrations.migration_manager import MigrationManager
235
- manager = MigrationManager(self.db_path)
236
- pending = manager.get_pending_migrations()
237
- if pending:
238
- logger.info("Running pending migrations for fresh database", extra={
239
- "pending_count": len(pending)
240
- })
241
- manager.migrate()
242
- logger.info("Migrations completed successfully", extra={
243
- "count": len(pending)
268
+
269
+ # For FRESH databases: Run migrations after schema.sql loads
270
+ # (Existing DBs already had migrations run before schema.sql)
271
+ if not is_existing_db:
272
+ try:
273
+ from .migrations.migration_manager import MigrationManager
274
+ manager = MigrationManager(self.db_path)
275
+ pending = manager.get_pending_migrations()
276
+ if pending:
277
+ logger.info("Running pending migrations for fresh database", extra={
278
+ "pending_count": len(pending)
279
+ })
280
+ manager.migrate()
281
+ logger.info("Migrations completed successfully", extra={
282
+ "count": len(pending)
283
+ })
284
+ except Exception as migration_error:
285
+ logger.error("Failed to run migrations", extra={
286
+ "error": str(migration_error),
287
+ "error_type": type(migration_error).__name__,
288
+ "traceback": traceback.format_exc()
244
289
  })
245
- except Exception as migration_error:
246
- logger.error("Failed to run migrations", extra={
247
- "error": str(migration_error),
248
- "error_type": type(migration_error).__name__,
249
- "traceback": traceback.format_exc()
250
- })
251
290
 
252
291
  except Exception as e:
253
292
  logger.error("Database initialization failed", extra={
souleyez/storage/hosts.py CHANGED
@@ -28,9 +28,12 @@ class HostManager:
28
28
  if not ip:
29
29
  raise ValueError("Host must have an IP address")
30
30
 
31
+ # Determine scope status for this host
32
+ scope_status = self._determine_scope_status(engagement_id, ip)
33
+
31
34
  # Check if host already exists
32
35
  existing = self.db.execute_one(
33
- "SELECT id FROM hosts WHERE engagement_id = ? AND ip_address = ?",
36
+ "SELECT id, scope_status FROM hosts WHERE engagement_id = ? AND ip_address = ?",
34
37
  (engagement_id, ip)
35
38
  )
36
39
 
@@ -43,6 +46,10 @@ class HostManager:
43
46
  # Always update status
44
47
  update_data['status'] = host_data.get('status', 'up')
45
48
 
49
+ # Update scope_status if it was unknown and we now have a determination
50
+ if existing.get('scope_status') == 'unknown' and scope_status != 'unknown':
51
+ update_data['scope_status'] = scope_status
52
+
46
53
  # Only update these fields if they have values
47
54
  if host_data.get('hostname'):
48
55
  update_data['hostname'] = host_data['hostname']
@@ -71,11 +78,89 @@ class HostManager:
71
78
  'os_name': host_data.get('os'),
72
79
  'mac_address': host_data.get('mac_address'),
73
80
  'os_accuracy': host_data.get('os_accuracy'),
74
- 'status': host_data.get('status', 'up')
81
+ 'status': host_data.get('status', 'up'),
82
+ 'scope_status': scope_status
75
83
  })
76
84
 
77
85
  return host_id
78
86
 
87
+ def _determine_scope_status(self, engagement_id: int, ip: str) -> str:
88
+ """
89
+ Determine scope status for a host based on engagement scope.
90
+
91
+ Args:
92
+ engagement_id: Engagement ID
93
+ ip: IP address to check
94
+
95
+ Returns:
96
+ 'in_scope', 'out_of_scope', or 'unknown'
97
+ """
98
+ try:
99
+ from souleyez.security.scope_validator import ScopeValidator
100
+ validator = ScopeValidator(engagement_id)
101
+ if validator.has_scope_defined():
102
+ result = validator.validate_ip(ip)
103
+ return 'in_scope' if result.is_in_scope else 'out_of_scope'
104
+ return 'unknown' # No scope defined
105
+ except Exception as e:
106
+ logger.warning(f"Failed to determine scope status for {ip}: {e}")
107
+ return 'unknown'
108
+
109
+ def update_scope_status(self, host_id: int, scope_status: str) -> bool:
110
+ """
111
+ Update scope status for a host.
112
+
113
+ Args:
114
+ host_id: Host ID
115
+ scope_status: 'in_scope', 'out_of_scope', or 'unknown'
116
+
117
+ Returns:
118
+ True if successful
119
+ """
120
+ valid_statuses = ['in_scope', 'out_of_scope', 'unknown']
121
+ if scope_status not in valid_statuses:
122
+ raise ValueError(f"Invalid scope_status: {scope_status}. Must be one of: {valid_statuses}")
123
+
124
+ try:
125
+ self.db.execute(
126
+ "UPDATE hosts SET scope_status = ? WHERE id = ?",
127
+ (scope_status, host_id)
128
+ )
129
+ return True
130
+ except Exception:
131
+ return False
132
+
133
+ def revalidate_scope_status(self, engagement_id: int) -> Dict[str, int]:
134
+ """
135
+ Revalidate scope status for all hosts in an engagement.
136
+
137
+ Call this after scope entries are added/modified to update all hosts.
138
+
139
+ Args:
140
+ engagement_id: Engagement ID
141
+
142
+ Returns:
143
+ {'updated': N, 'in_scope': X, 'out_of_scope': Y}
144
+ """
145
+ hosts = self.list_hosts(engagement_id)
146
+ updated = 0
147
+ in_scope = 0
148
+ out_of_scope = 0
149
+
150
+ for host in hosts:
151
+ ip = host.get('ip') or host.get('ip_address')
152
+ new_status = self._determine_scope_status(engagement_id, ip)
153
+ if new_status != host.get('scope_status'):
154
+ self.update_scope_status(host['id'], new_status)
155
+ updated += 1
156
+
157
+ if new_status == 'in_scope':
158
+ in_scope += 1
159
+ elif new_status == 'out_of_scope':
160
+ out_of_scope += 1
161
+
162
+ return {'updated': updated, 'in_scope': in_scope, 'out_of_scope': out_of_scope}
163
+
79
164
  def add_service(self, host_id: int, service_data: Dict[str, Any]) -> int:
80
165
  """
81
166
  Add or update a service for a host.
@@ -0,0 +1,87 @@
1
+ """
2
+ Migration 026: Add Engagement Scope Validation
3
+
4
+ Tables created:
5
+ - engagement_scope: Structured scope definitions (CIDR, domains, URLs)
6
+ - scope_validation_log: Audit trail of scope validation decisions
7
+
8
+ Columns added:
9
+ - engagements.scope_enforcement: Enforcement mode (off, warn, block)
10
+ - hosts.scope_status: Host scope status (in_scope, out_of_scope, unknown)
11
+ """
12
+ import os
13
+
14
+
15
+ def upgrade(conn):
16
+ """Add scope validation tables and columns."""
17
+
18
+ # Engagement scope definitions table
19
+ conn.execute("""
20
+ CREATE TABLE IF NOT EXISTS engagement_scope (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ engagement_id INTEGER NOT NULL,
23
+ scope_type TEXT NOT NULL,
24
+ value TEXT NOT NULL,
25
+ is_excluded BOOLEAN DEFAULT 0,
26
+ description TEXT,
27
+ added_by TEXT,
28
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
29
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE,
30
+ UNIQUE(engagement_id, scope_type, value)
31
+ )
32
+ """)
33
+
34
+ # Scope validation audit log
35
+ conn.execute("""
36
+ CREATE TABLE IF NOT EXISTS scope_validation_log (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ engagement_id INTEGER NOT NULL,
39
+ job_id INTEGER,
40
+ target TEXT NOT NULL,
41
+ validation_result TEXT NOT NULL,
42
+ action_taken TEXT NOT NULL,
43
+ matched_scope_id INTEGER,
44
+ user_id TEXT,
45
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
46
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE
47
+ )
48
+ """)
49
+
50
+ # Add scope_enforcement column to engagements
51
+ try:
52
+ conn.execute("ALTER TABLE engagements ADD COLUMN scope_enforcement TEXT DEFAULT 'off'")
53
+ except Exception:
54
+ pass # Column may already exist
55
+
56
+ # Add scope_status column to hosts
57
+ try:
58
+ conn.execute("ALTER TABLE hosts ADD COLUMN scope_status TEXT DEFAULT 'unknown'")
59
+ except Exception:
60
+ pass # Column may already exist
61
+
62
+ # Indexes for performance
63
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_scope_engagement ON engagement_scope(engagement_id)")
64
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_scope_type ON engagement_scope(scope_type)")
65
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_scope_log_engagement ON scope_validation_log(engagement_id)")
66
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_scope_log_result ON scope_validation_log(validation_result)")
67
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_scope_log_timestamp ON scope_validation_log(created_at DESC)")
68
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_hosts_scope_status ON hosts(scope_status)")
69
+
70
+ conn.commit()
71
+
72
+ if not os.environ.get('SOULEYEZ_MIGRATION_SILENT'):
73
+ print("Migration 026: Engagement scope validation tables created")
74
+
75
+
76
+ def downgrade(conn):
77
+ """Remove scope validation tables."""
78
+ conn.execute("DROP TABLE IF EXISTS scope_validation_log")
79
+ conn.execute("DROP TABLE IF EXISTS engagement_scope")
80
+ conn.execute("DROP INDEX IF EXISTS idx_scope_engagement")
81
+ conn.execute("DROP INDEX IF EXISTS idx_scope_type")
82
+ conn.execute("DROP INDEX IF EXISTS idx_scope_log_engagement")
83
+ conn.execute("DROP INDEX IF EXISTS idx_scope_log_result")
84
+ conn.execute("DROP INDEX IF EXISTS idx_scope_log_timestamp")
85
+ conn.execute("DROP INDEX IF EXISTS idx_hosts_scope_status")
86
+ # Note: Cannot easily drop columns in SQLite, they remain but unused
87
+ print("Migration 026: Engagement scope validation tables dropped")
@@ -0,0 +1,119 @@
1
+ """
2
+ Migration 027: Multi-SIEM Persistence
3
+
4
+ Changes wazuh_config table to support multiple SIEM configs per engagement.
5
+ - Removes UNIQUE constraint on engagement_id
6
+ - Adds UNIQUE constraint on (engagement_id, siem_type)
7
+ - Allows each engagement to have separate configs for Wazuh, Splunk, etc.
8
+ """
9
+ import os
10
+
11
+
12
+ def upgrade(conn):
13
+ """Migrate to multi-SIEM persistence."""
14
+ cursor = conn.cursor()
15
+
16
+ # Check if siem_type column exists (from migration 025)
17
+ cursor.execute("PRAGMA table_info(wazuh_config)")
18
+ columns = [col[1] for col in cursor.fetchall()]
19
+
20
+ if 'siem_type' not in columns:
21
+ cursor.execute("ALTER TABLE wazuh_config ADD COLUMN siem_type TEXT DEFAULT 'wazuh'")
22
+ if 'config_json' not in columns:
23
+ cursor.execute("ALTER TABLE wazuh_config ADD COLUMN config_json TEXT")
24
+
25
+ # Create new table with correct constraint
26
+ cursor.execute("""
27
+ CREATE TABLE IF NOT EXISTS siem_config_new (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ engagement_id INTEGER NOT NULL,
30
+ siem_type TEXT NOT NULL DEFAULT 'wazuh',
31
+ api_url TEXT,
32
+ api_user TEXT,
33
+ api_password TEXT,
34
+ indexer_url TEXT,
35
+ indexer_user TEXT DEFAULT 'admin',
36
+ indexer_password TEXT,
37
+ verify_ssl BOOLEAN DEFAULT 0,
38
+ enabled BOOLEAN DEFAULT 1,
39
+ config_json TEXT,
40
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
41
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
42
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE,
43
+ UNIQUE(engagement_id, siem_type)
44
+ )
45
+ """)
46
+
47
+ # Copy existing data
48
+ cursor.execute("""
49
+ INSERT OR IGNORE INTO siem_config_new (
50
+ id, engagement_id, siem_type, api_url, api_user, api_password,
51
+ indexer_url, indexer_user, indexer_password, verify_ssl, enabled,
52
+ config_json, created_at, updated_at
53
+ )
54
+ SELECT
55
+ id, engagement_id, COALESCE(siem_type, 'wazuh'), api_url, api_user, api_password,
56
+ indexer_url, indexer_user, indexer_password, verify_ssl, enabled,
57
+ config_json, created_at, updated_at
58
+ FROM wazuh_config
59
+ """)
60
+
61
+ # Drop old table and rename new one
62
+ cursor.execute("DROP TABLE wazuh_config")
63
+ cursor.execute("ALTER TABLE siem_config_new RENAME TO wazuh_config")
64
+
65
+ # Recreate index
66
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_wazuh_config_engagement ON wazuh_config(engagement_id)")
67
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_wazuh_config_siem_type ON wazuh_config(siem_type)")
68
+
69
+ conn.commit()
70
+
71
+ if not os.environ.get('SOULEYEZ_MIGRATION_SILENT'):
72
+ print("Migration 027: Multi-SIEM persistence enabled")
73
+
74
+
75
+ def downgrade(conn):
76
+ """Revert to single SIEM per engagement (lossy - keeps only first config per engagement)."""
77
+ cursor = conn.cursor()
78
+
79
+ cursor.execute("""
80
+ CREATE TABLE IF NOT EXISTS wazuh_config_old (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ engagement_id INTEGER NOT NULL UNIQUE,
83
+ api_url TEXT NOT NULL,
84
+ api_user TEXT NOT NULL,
85
+ api_password TEXT,
86
+ indexer_url TEXT,
87
+ indexer_user TEXT DEFAULT 'admin',
88
+ indexer_password TEXT,
89
+ verify_ssl BOOLEAN DEFAULT 0,
90
+ enabled BOOLEAN DEFAULT 1,
91
+ siem_type TEXT DEFAULT 'wazuh',
92
+ config_json TEXT,
93
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
94
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE
96
+ )
97
+ """)
98
+
99
+ # Copy only first config per engagement
100
+ cursor.execute("""
101
+ INSERT OR IGNORE INTO wazuh_config_old (
102
+ engagement_id, api_url, api_user, api_password,
103
+ indexer_url, indexer_user, indexer_password, verify_ssl, enabled,
104
+ siem_type, config_json, created_at, updated_at
105
+ )
106
+ SELECT
107
+ engagement_id, api_url, api_user, api_password,
108
+ indexer_url, indexer_user, indexer_password, verify_ssl, enabled,
109
+ siem_type, config_json, created_at, updated_at
110
+ FROM wazuh_config
111
+ GROUP BY engagement_id
112
+ """)
113
+
114
+ cursor.execute("DROP TABLE wazuh_config")
115
+ cursor.execute("ALTER TABLE wazuh_config_old RENAME TO wazuh_config")
116
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_wazuh_config_engagement ON wazuh_config(engagement_id)")
117
+
118
+ conn.commit()
119
+ print("Migration 027: Reverted to single SIEM per engagement")
@@ -29,6 +29,9 @@ from . import (
29
29
  _022_wazuh_indexer_columns,
30
30
  _023_fix_detection_results_fk,
31
31
  _024_wazuh_vulnerabilities,
32
+ _025_multi_siem_support,
33
+ _026_add_engagement_scope,
34
+ _027_multi_siem_persistence,
32
35
  )
33
36
 
34
37
  # Migration registry - maps version to module
@@ -56,6 +59,9 @@ MIGRATIONS_REGISTRY = {
56
59
  '022': _022_wazuh_indexer_columns,
57
60
  '023': _023_fix_detection_results_fk,
58
61
  '024': _024_wazuh_vulnerabilities,
62
+ '025': _025_multi_siem_support,
63
+ '026': _026_add_engagement_scope,
64
+ '027': _027_multi_siem_persistence,
59
65
  }
60
66
 
61
67
 
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS engagements (
6
6
  owner_id INTEGER,
7
7
  estimated_hours FLOAT DEFAULT 0,
8
8
  actual_hours FLOAT DEFAULT 0,
9
+ scope_enforcement TEXT DEFAULT 'off',
9
10
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
10
11
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
11
12
  );
@@ -21,6 +22,7 @@ CREATE TABLE IF NOT EXISTS hosts (
21
22
  mac_address TEXT,
22
23
  status TEXT DEFAULT 'up',
23
24
  access_level TEXT DEFAULT 'none',
25
+ scope_status TEXT DEFAULT 'unknown',
24
26
  notes TEXT,
25
27
  tags TEXT,
26
28
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -502,18 +504,21 @@ CREATE INDEX IF NOT EXISTS idx_msf_sessions_active ON msf_sessions(is_active);
502
504
  -- Wazuh SIEM Integration (Detection Validation)
503
505
  CREATE TABLE IF NOT EXISTS wazuh_config (
504
506
  id INTEGER PRIMARY KEY AUTOINCREMENT,
505
- engagement_id INTEGER NOT NULL UNIQUE,
506
- api_url TEXT NOT NULL,
507
- api_user TEXT NOT NULL,
507
+ engagement_id INTEGER NOT NULL,
508
+ siem_type TEXT NOT NULL DEFAULT 'wazuh',
509
+ api_url TEXT,
510
+ api_user TEXT,
508
511
  api_password TEXT,
509
512
  indexer_url TEXT,
510
513
  indexer_user TEXT DEFAULT 'admin',
511
514
  indexer_password TEXT,
512
515
  verify_ssl BOOLEAN DEFAULT 0,
513
516
  enabled BOOLEAN DEFAULT 1,
517
+ config_json TEXT,
514
518
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
515
519
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
516
- FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE
520
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE,
521
+ UNIQUE(engagement_id, siem_type)
517
522
  );
518
523
 
519
524
  -- Detection validation results per job
@@ -537,6 +542,41 @@ CREATE TABLE IF NOT EXISTS detection_results (
537
542
  );
538
543
 
539
544
  CREATE INDEX IF NOT EXISTS idx_wazuh_config_engagement ON wazuh_config(engagement_id);
545
+ CREATE INDEX IF NOT EXISTS idx_wazuh_config_siem_type ON wazuh_config(siem_type);
540
546
  CREATE INDEX IF NOT EXISTS idx_detection_results_job ON detection_results(job_id);
541
547
  CREATE INDEX IF NOT EXISTS idx_detection_results_engagement ON detection_results(engagement_id);
542
548
  CREATE INDEX IF NOT EXISTS idx_detection_results_status ON detection_results(detection_status);
549
+
550
+ -- Engagement Scope Validation (from migration 026)
551
+ CREATE TABLE IF NOT EXISTS engagement_scope (
552
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
553
+ engagement_id INTEGER NOT NULL,
554
+ scope_type TEXT NOT NULL,
555
+ value TEXT NOT NULL,
556
+ is_excluded BOOLEAN DEFAULT 0,
557
+ description TEXT,
558
+ added_by TEXT,
559
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
560
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE,
561
+ UNIQUE(engagement_id, scope_type, value)
562
+ );
563
+
564
+ CREATE TABLE IF NOT EXISTS scope_validation_log (
565
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
566
+ engagement_id INTEGER NOT NULL,
567
+ job_id INTEGER,
568
+ target TEXT NOT NULL,
569
+ validation_result TEXT NOT NULL,
570
+ action_taken TEXT NOT NULL,
571
+ matched_scope_id INTEGER,
572
+ user_id TEXT,
573
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
574
+ FOREIGN KEY (engagement_id) REFERENCES engagements(id) ON DELETE CASCADE
575
+ );
576
+
577
+ CREATE INDEX IF NOT EXISTS idx_scope_engagement ON engagement_scope(engagement_id);
578
+ CREATE INDEX IF NOT EXISTS idx_scope_type ON engagement_scope(scope_type);
579
+ CREATE INDEX IF NOT EXISTS idx_scope_log_engagement ON scope_validation_log(engagement_id);
580
+ CREATE INDEX IF NOT EXISTS idx_scope_log_result ON scope_validation_log(validation_result);
581
+ CREATE INDEX IF NOT EXISTS idx_scope_log_timestamp ON scope_validation_log(created_at DESC);
582
+ CREATE INDEX IF NOT EXISTS idx_hosts_scope_status ON hosts(scope_status);