ghostbase-cli 0.1.4__tar.gz → 0.1.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
5
5
  Author-email: 4yk5 <contact.4yk5@proton.me>
6
6
  License-Expression: MIT
@@ -314,7 +314,8 @@ class GhostBaseApp(App):
314
314
  self.db_path = db_path or "sample.db"
315
315
  self.populate_sample = populate_sample
316
316
  self.db = DatabaseEngine(self.db_path)
317
- self.safe_mode = read_only
317
+ self._cli_read_only = bool(read_only)
318
+ self.safe_mode = bool(read_only)
318
319
  self.last_columns: list = []
319
320
  self.last_rows: list = []
320
321
  self.last_query_table: str = None
@@ -334,15 +335,19 @@ class GhostBaseApp(App):
334
335
  if self.db.db_type != "sqlite"
335
336
  else os.path.basename(self.db_path)
336
337
  )
337
- safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
338
- safe_class = "" if self.safe_mode else "unsafe"
338
+ if self._cli_read_only:
339
+ safe_text = "[SAFE: LOCKED]"
340
+ safe_class = ""
341
+ else:
342
+ safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
343
+ safe_class = "" if self.safe_mode else "unsafe"
339
344
 
340
345
  with Horizontal(id="header-bar"):
341
346
  yield Label("GHOSTBASE", id="title-label")
342
347
  yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
343
348
  yield Label(db_label, id="db-name-label")
344
349
  yield Label(safe_text, id="safe-badge", classes=safe_class)
345
- yield Label("v0.1.4", id="version-label")
350
+ yield Label("v0.1.5", id="version-label")
346
351
 
347
352
  with Horizontal(id="main-container"):
348
353
  with Vertical(id="sidebar"):
@@ -781,7 +786,7 @@ class GhostBaseApp(App):
781
786
  def action_explain_query(self):
782
787
  """Execute 1-Key EXPLAIN Query Plan (Ctrl+P)"""
783
788
  raw = self.query_one("#query-box", TextArea).text.strip()
784
- lines = [l for l in raw.splitlines() if not l.strip().startswith("--")]
789
+ lines = [l for l in raw.splitlines() if not l.strip().startswith(("--", "#"))]
785
790
  query = "\n".join(lines).strip()
786
791
  if not query:
787
792
  self.notify("No query to explain", severity="warning")
@@ -791,6 +796,19 @@ class GhostBaseApp(App):
791
796
  status = self.query_one("#status-label", Label)
792
797
  res_cnt = self.query_one("#results-count", Label)
793
798
 
799
+ # Central safety verification before EXPLAIN
800
+ statements = self.db.split_statements(query)
801
+ if len(statements) > 1:
802
+ status.update("[bold red]EXPLAIN ERROR[/] Multiple statements not allowed in EXPLAIN")
803
+ self.notify("EXPLAIN only supports a single SQL statement.", severity="error", timeout=4)
804
+ return
805
+
806
+ is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
807
+ if self.safe_mode and is_destructive:
808
+ status.update(f"[bold red]BLOCKED[/] {reason}")
809
+ self.notify(f"Cannot explain query in Safe Mode: {reason}", severity="error", timeout=5)
810
+ return
811
+
794
812
  res = self.db.explain_query(query)
795
813
  dt.clear(columns=True)
796
814
 
@@ -816,6 +834,10 @@ class GhostBaseApp(App):
816
834
 
817
835
  def action_toggle_safe_mode(self):
818
836
  """Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
837
+ if getattr(self, "_cli_read_only", False):
838
+ self.notify("CLI --read-only flag is active. Safe Mode is locked and cannot be disabled.", severity="warning", timeout=4)
839
+ return
840
+
819
841
  new_mode = not self.safe_mode
820
842
  self._pending_destructive_query = None
821
843
  self._pending_destructive_time = 0.0
@@ -353,16 +353,38 @@ class DatabaseEngine:
353
353
 
354
354
  # --- Query safety and formatting ---
355
355
 
356
+ @staticmethod
357
+ def _mask_strings(sql: str) -> Tuple[str, List[str]]:
358
+ """
359
+ Extract and mask string literals (data values):
360
+ - Plain untagged dollar-quotes: $$...$$
361
+ - Tagged dollar-quotes: $tag$...$tag$
362
+ - PostgreSQL E-strings: E'...'
363
+ - Standard single-quoted strings: '...'
364
+ """
365
+ literals = []
366
+ def _repl(m):
367
+ idx = len(literals)
368
+ literals.append(m.group(0))
369
+ return f"___GB_STR_{idx}___"
370
+
371
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*')"
372
+ masked = re.sub(pattern, _repl, sql)
373
+ return masked, literals
374
+
375
+ @staticmethod
376
+ def _strip_comments(sql: str) -> str:
377
+ """Strip block comments /* ... */, line comments -- ..., and MySQL # ..."""
378
+ c = re.sub(r"/\*[\s\S]*?\*/", " ", sql)
379
+ c = re.sub(r"--[^\r\n]*", " ", c)
380
+ c = re.sub(r"#[^\r\n]*", " ", c)
381
+ return c
382
+
356
383
  @staticmethod
357
384
  def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
358
385
  """
359
386
  Extract and mask all string literals and quoted identifiers:
360
- - Plain untagged dollar-quotes: $$...$$
361
- - Tagged dollar-quotes with strict matching backreference \2: $tag$...$tag$
362
- - PostgreSQL E-strings: E'...' (supporting backslash escapes \')
363
- - Standard single-quoted strings: '...' (supporting \' and '')
364
- - Standard double-quoted strings / identifiers: "..."
365
- - MySQL backtick identifiers: `...`
387
+ Used by SQL formatter to preserve both literal values and identifier cases.
366
388
  """
367
389
  literals = []
368
390
  def _repl(m):
@@ -370,31 +392,78 @@ class DatabaseEngine:
370
392
  literals.append(m.group(0))
371
393
  return f" ___GB_LIT_{idx}___ "
372
394
 
373
- pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`)"
395
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
374
396
  masked = re.sub(pattern, _repl, sql)
375
397
  return masked, literals
376
398
 
377
- def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
399
+ @classmethod
400
+ def split_statements(cls, sql: str) -> List[str]:
378
401
  """
379
- Check query safety.
380
- - Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
381
- - Comments (-- , /* */, and MySQL #) are stripped second.
382
- - In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions.
383
- - In Normal Mode: Intercepts unconstrained destructive queries.
402
+ Split a multi-statement SQL script into individual statements by semicolon.
403
+ Guarantees that semicolons inside string literals, dollar quotes, comments,
404
+ and quoted identifiers do not cause incorrect statement boundaries.
384
405
  """
385
- # 1. Mask all string literals and dollar-quoted strings FIRST
386
- masked, _ = self._mask_strings_and_literals(query)
387
-
388
- # 2. Strip block comments /* ... */, line comments -- ..., and MySQL # ...
389
- q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
390
- q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
391
- q_no_comments = re.sub(r"#[^\r\n]*", " ", q_no_comments)
406
+ # Mask both strings and quoted identifiers so semicolons inside them are preserved
407
+ tokens = []
408
+ def _repl_tok(m):
409
+ idx = len(tokens)
410
+ tokens.append(m.group(0))
411
+ return f"___GB_TOK_{idx}___"
412
+
413
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
414
+ masked = re.sub(pattern, _repl_tok, sql)
415
+ clean = cls._strip_comments(masked)
416
+ parts = clean.split(";")
417
+ stmts = []
418
+ for p in parts:
419
+ p_str = p.strip()
420
+ if p_str:
421
+ for idx, tok in enumerate(tokens):
422
+ p_str = p_str.replace(f"___GB_TOK_{idx}___", tok)
423
+ stmts.append(p_str)
424
+ return stmts
392
425
 
393
- q_clean = q_no_comments.strip()
394
- if not q_clean:
426
+ @staticmethod
427
+ def _normalize_identifiers_for_safety(sql: str) -> str:
428
+ """
429
+ Normalize quoted identifiers ("set_config", `setval`, [col]) for safety checks:
430
+ - Quoted function/object names (e.g. "set_config", "setval", "delete") are unquoted
431
+ so that identifier quotes cannot conceal restricted keywords or functions.
432
+ - If an identifier is literally "WHERE", it represents a column/alias name and CANNOT
433
+ act as a SQL clause keyword; it is masked to ___GB_ID_WHERE___ so it does not
434
+ falsely satisfy the WHERE requirement of UPDATE/DELETE.
435
+ """
436
+ def _repl_ident(m):
437
+ raw = m.group(0)
438
+ inner = raw[1:-1]
439
+ if raw.startswith('"'):
440
+ inner = inner.replace('""', '"')
441
+ elif raw.startswith('`'):
442
+ inner = inner.replace('``', '`')
443
+ elif raw.startswith('['):
444
+ inner = inner.replace(']]', ']')
445
+
446
+ if inner.strip().upper() == "WHERE":
447
+ return " ___GB_ID_WHERE___ "
448
+ return f" {inner} "
449
+
450
+ ident_pattern = r"(\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
451
+ return re.sub(ident_pattern, _repl_ident, sql)
452
+
453
+ def _check_single_statement_safety(self, stmt: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
454
+ """Evaluate safety on an individual, isolated SQL statement."""
455
+ # 1. Mask data literals (single quotes, dollar-quotes, E-strings)
456
+ masked, _ = self._mask_strings(stmt)
457
+
458
+ # 2. Strip comments
459
+ q_no_comments = self._strip_comments(masked)
460
+
461
+ # 3. Normalize identifiers (unquote "set_config", protect "where" columns)
462
+ q_norm = self._normalize_identifiers_for_safety(q_no_comments).strip()
463
+ if not q_norm:
395
464
  return False, ""
396
465
 
397
- q_upper = q_clean.upper()
466
+ q_upper = q_norm.upper()
398
467
 
399
468
  # SAFE MODE: STRICT READ-ONLY ENFORCEMENT
400
469
  if is_safe_mode:
@@ -415,11 +484,11 @@ class DatabaseEngine:
415
484
  (r"\bDO\b", "DO procedural block"),
416
485
  (r"\bCALL\b", "CALL stored procedure"),
417
486
  (r"\bMERGE\b", "MERGE statement"),
418
- (r"\bSET_CONFIG\s*\(", "SET_CONFIG parameter modification"),
419
- (r"\bSETVAL\s*\(", "SETVAL sequence mutation"),
420
- (r"\bNEXTVAL\s*\(", "NEXTVAL sequence advance"),
421
- (r"\bCURRVAL\s*\(", "CURRVAL sequence check"),
422
- (r"\bLASTVAL\s*\(", "LASTVAL sequence check"),
487
+ (r"\bSET_CONFIG\b", "SET_CONFIG parameter modification"),
488
+ (r"\bSETVAL\b", "SETVAL sequence mutation"),
489
+ (r"\bNEXTVAL\b", "NEXTVAL sequence advance"),
490
+ (r"\bCURRVAL\b", "CURRVAL sequence check"),
491
+ (r"\bLASTVAL\b", "LASTVAL sequence check"),
423
492
  (r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
424
493
  (r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
425
494
  (r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
@@ -437,7 +506,7 @@ class DatabaseEngine:
437
506
 
438
507
  return False, ""
439
508
 
440
- # NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops)
509
+ # NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops per statement)
441
510
  if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW)\b", q_upper):
442
511
  return True, "DROP operation detected"
443
512
  if re.search(r"\bTRUNCATE\b", q_upper):
@@ -451,21 +520,56 @@ class DatabaseEngine:
451
520
 
452
521
  return False, ""
453
522
 
454
- def explain_query(self, query: str) -> Dict[str, Any]:
455
- masked, literals = self._mask_strings_and_literals(query)
456
- clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
457
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
458
- clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().rstrip(";")
459
- for idx, orig in enumerate(literals):
460
- clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
523
+ def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
524
+ """
525
+ Check script safety across all statements.
526
+ Multi-statement queries are evaluated individually: if ANY statement is destructive
527
+ or violates Safe Mode, the entire batch is flagged.
528
+ """
529
+ statements = self.split_statements(query)
530
+ if not statements:
531
+ return False, ""
532
+
533
+ for stmt in statements:
534
+ is_dest, reason = self._check_single_statement_safety(stmt, is_safe_mode=is_safe_mode)
535
+ if is_dest:
536
+ return True, reason
461
537
 
462
- if not clean_q:
538
+ return False, ""
539
+
540
+ def explain_query(self, query: str) -> Dict[str, Any]:
541
+ """
542
+ Explain query execution plan.
543
+ Strictly allows only a single read statement. Multi-statement batches or destructive
544
+ operations are strictly rejected to prevent execution bypasses.
545
+ """
546
+ statements = self.split_statements(query)
547
+ if not statements:
463
548
  return {"success": False, "error": "Query is empty"}
549
+ if len(statements) > 1:
550
+ return {
551
+ "success": False,
552
+ "error": "EXPLAIN only supports a single SQL statement. Multiple statements are not permitted.",
553
+ }
554
+
555
+ single_q = statements[0].strip().rstrip(";")
556
+
557
+ # Validate safety on the single statement to be explained
558
+ is_dest, reason = self.check_safety(single_q, is_safe_mode=False)
559
+
560
+ # Detect EXPLAIN ANALYZE on destructive queries (PostgreSQL executes the query under ANALYZE)
561
+ masked, _ = self._mask_strings(single_q)
562
+ clean = self._strip_comments(masked).strip().upper()
563
+ if re.search(r"\b(ANALYZE|EXECUTE)\b", clean) and is_dest:
564
+ return {
565
+ "success": False,
566
+ "error": f"EXPLAIN with execution side-effects on destructive statements is blocked: {reason}",
567
+ }
464
568
 
465
569
  if self.db_type == "sqlite":
466
- explain_sql = f"EXPLAIN QUERY PLAN {clean_q};"
570
+ explain_sql = f"EXPLAIN QUERY PLAN {single_q};"
467
571
  else:
468
- explain_sql = f"EXPLAIN {clean_q};"
572
+ explain_sql = f"EXPLAIN {single_q};"
469
573
  return self.execute_query(explain_sql)
470
574
 
471
575
  @staticmethod
@@ -528,6 +632,33 @@ class DatabaseEngine:
528
632
  result_parts.append(sql_text.strip())
529
633
  return "\n".join(result_parts)
530
634
 
635
+ @classmethod
636
+ def is_mysql_select_needing_limit(cls, query: str) -> bool:
637
+ """
638
+ Check if a query is a top-level SELECT/WITH in MySQL that lacks a top-level LIMIT clause.
639
+ Guarantees that SHOW, DESCRIBE, EXPLAIN, and subqueries with LIMIT are handled correctly.
640
+ """
641
+ masked, _ = cls._mask_strings(query)
642
+ clean = cls._strip_comments(masked).strip()
643
+
644
+ # Must start with SELECT or WITH
645
+ if not re.match(r"^(SELECT|WITH)\b", clean, flags=re.IGNORECASE):
646
+ return False
647
+
648
+ # Check if there is a top-level LIMIT (at parenthesis depth 0)
649
+ depth = 0
650
+ tokens = re.split(r"(\(|\)|\bLIMIT\b)", clean, flags=re.IGNORECASE)
651
+ for token in tokens:
652
+ if token == "(":
653
+ depth += 1
654
+ elif token == ")":
655
+ if depth > 0:
656
+ depth -= 1
657
+ elif token.upper() == "LIMIT":
658
+ if depth == 0:
659
+ return False # Already has a top-level LIMIT
660
+ return True
661
+
531
662
  # --- Query execution ---
532
663
 
533
664
  def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
@@ -584,7 +715,7 @@ class DatabaseEngine:
584
715
  # MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
585
716
  exec_query = query
586
717
  if self.db_type == "mysql" and not is_write_dml:
587
- if not re.search(r"\bLIMIT\b", clean_q, flags=re.IGNORECASE):
718
+ if self.is_mysql_select_needing_limit(query):
588
719
  exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
589
720
 
590
721
  cur = self.conn.cursor()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL
5
5
  Author-email: 4yk5 <contact.4yk5@proton.me>
6
6
  License-Expression: MIT
@@ -112,6 +112,9 @@ def main():
112
112
  try:
113
113
  app = GhostBaseApp(db_path=conn, read_only=read_only)
114
114
  app.run()
115
+ ret = getattr(app, "return_code", 0)
116
+ if isinstance(ret, int) and ret != 0:
117
+ sys.exit(ret)
115
118
  except Exception as e:
116
119
  print(f"\n[GhostBase Error] Failed to connect to database: {e}\n")
117
120
  sys.exit(1)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghostbase-cli"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "4yk5", email = "contact.4yk5@proton.me" }]
@@ -302,9 +302,145 @@ def test_result_set_memory_limit():
302
302
  print(" [+] Result Set Memory Limit: PASS")
303
303
 
304
304
 
305
+ def test_quoted_identifier_safety():
306
+ print("[*] 10. Testing Quoted Identifier Safety (Safe Mode & WHERE Column Guards)...")
307
+ db = DatabaseEngine(":memory:")
308
+
309
+ # Safe Mode: Quoted set_config, setval, nextval must NOT bypass detection
310
+ quoted_unsafe_queries = [
311
+ 'SELECT pg_catalog."set_config"(\'default_transaction_read_only\', \'off\', false);',
312
+ 'SELECT "setval"(\'users_id_seq\', 1, false);',
313
+ 'SELECT "nextval"(\'users_id_seq\');',
314
+ 'SELECT `set_config`(\'default_transaction_read_only\', \'off\', false);',
315
+ 'SELECT [setval](\'users_id_seq\', 1);',
316
+ 'SELECT "pg_terminate_backend"(1234);',
317
+ ]
318
+ for q in quoted_unsafe_queries:
319
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
320
+ assert is_dest, f"VULNERABILITY: Quoted function bypassed Safe Mode: {q}"
321
+ assert "Safe Mode is active" in r
322
+
323
+ # Normal Mode: Column named "where" or `where` must NOT masquerade as WHERE clause
324
+ col_where_queries = [
325
+ 'UPDATE users SET "where" = 1;',
326
+ 'UPDATE users SET `where` = 1;',
327
+ 'UPDATE users SET [where] = 1;',
328
+ 'DELETE FROM users WHERE false = false;', # Real WHERE -> safe
329
+ ]
330
+ # First 3 must be detected as UPDATE without WHERE
331
+ for q in col_where_queries[:3]:
332
+ is_dest, r = db.check_safety(q, is_safe_mode=False)
333
+ assert is_dest, f"VULNERABILITY: Quoted 'where' column bypassed WHERE check: {q}"
334
+ assert "UPDATE without WHERE" in r
335
+
336
+ # Real WHERE must pass
337
+ is_dest, r = db.check_safety(col_where_queries[3], is_safe_mode=False)
338
+ assert not is_dest, f"Valid WHERE clause falsely flagged: {col_where_queries[3]}"
339
+
340
+ print(" [+] Quoted Identifier Safety: PASS")
341
+
342
+
343
+ def test_multi_statement_safety():
344
+ print("[*] 11. Testing Multi-Statement SQL Safety Evaluation...")
345
+ db = DatabaseEngine(":memory:")
346
+
347
+ # Multi-statement injection: UPDATE without WHERE followed by SELECT with WHERE
348
+ multi_bypass = "UPDATE users SET role = 'Admin'; SELECT 1 WHERE true;"
349
+ is_dest, r = db.check_safety(multi_bypass, is_safe_mode=False)
350
+ assert is_dest, "VULNERABILITY: Multi-statement UPDATE without WHERE bypassed safety!"
351
+ assert "UPDATE without WHERE" in r
352
+
353
+ # Multi-statement: SELECT followed by unconditional DELETE
354
+ multi_delete = "SELECT 1; DELETE FROM users;"
355
+ is_dest, r = db.check_safety(multi_delete, is_safe_mode=False)
356
+ assert is_dest, "VULNERABILITY: Multi-statement DELETE without WHERE bypassed safety!"
357
+ assert "DELETE without WHERE" in r
358
+
359
+ # Safe multi-statement: Both statements have valid WHERE clauses
360
+ multi_safe = "UPDATE users SET role = 'Admin' WHERE id = 1; SELECT 1 WHERE true;"
361
+ is_dest, r = db.check_safety(multi_safe, is_safe_mode=False)
362
+ assert not is_dest, f"Safe multi-statement wrongly flagged: {multi_safe} ({r})"
363
+
364
+ # Safe Mode multi-statement: Any write in any statement must trigger Safe Mode block
365
+ safe_mode_multi = "SELECT 1; INSERT INTO audit (msg) VALUES ('ok');"
366
+ is_dest, r = db.check_safety(safe_mode_multi, is_safe_mode=True)
367
+ assert is_dest, "VULNERABILITY: INSERT in multi-statement bypassed Safe Mode!"
368
+ assert "Safe Mode is active" in r
369
+
370
+ # Statement splitter preserves semicolons inside strings and comments
371
+ complex_script = "SELECT 'one;two;three' AS s; -- comment; with; semicolons\nSELECT 2;"
372
+ stmts = db.split_statements(complex_script)
373
+ assert len(stmts) == 2, f"Expected 2 statements, got {len(stmts)}: {stmts}"
374
+ assert stmts[0] == "SELECT 'one;two;three' AS s"
375
+ assert stmts[1] == "SELECT 2"
376
+
377
+ print(" [+] Multi-Statement Safety: PASS")
378
+
379
+
380
+ def test_explain_query_safety():
381
+ print("[*] 12. Testing EXPLAIN Query Plan Safety (No Multi-Statement Injection)...")
382
+ db = DatabaseEngine(":memory:")
383
+ db.conn.execute("CREATE TABLE users (id INT, role TEXT);")
384
+ db.conn.execute("INSERT INTO users VALUES (1, 'User');")
385
+ db.conn.commit()
386
+
387
+ # Attempt multi-statement injection via EXPLAIN
388
+ injection = "SELECT 1; DELETE FROM users;"
389
+ res = db.explain_query(injection)
390
+ assert not res["success"], "VULNERABILITY: EXPLAIN allowed multi-statement execution!"
391
+ assert "single SQL statement" in res["error"]
392
+
393
+ # Verify that users table was NOT modified
394
+ cur = db.conn.cursor()
395
+ cur.execute("SELECT COUNT(*) FROM users;")
396
+ assert cur.fetchone()[0] == 1, "VULNERABILITY: Table data was modified during EXPLAIN injection!"
397
+
398
+ # Normal valid single EXPLAIN
399
+ valid_res = db.explain_query("SELECT * FROM users WHERE id = 1;")
400
+ assert valid_res["success"], f"Valid EXPLAIN failed: {valid_res.get('error')}"
401
+
402
+ print(" [+] EXPLAIN Query Plan Safety: PASS")
403
+
404
+
405
+ def test_mysql_auto_limit_logic():
406
+ print("[*] 13. Testing MySQL Auto-LIMIT Logic (Top-Level SELECT vs Non-SELECT/Subqueries)...")
407
+ test_cases = [
408
+ ("SHOW TABLES;", False),
409
+ ("SHOW DATABASES;", False),
410
+ ("DESCRIBE users;", False),
411
+ ("DESC users;", False),
412
+ ("EXPLAIN SELECT * FROM users;", False),
413
+ ("SELECT * FROM users;", True),
414
+ ("SELECT * FROM users LIMIT 10;", False),
415
+ ("SELECT * FROM huge WHERE id IN (SELECT id FROM t LIMIT 10);", True),
416
+ ("WITH cte AS (SELECT 1 AS n LIMIT 5) SELECT * FROM cte;", True),
417
+ ("WITH cte AS (SELECT 1 AS n) SELECT * FROM cte LIMIT 20;", False),
418
+ ]
419
+
420
+ for q, expected in test_cases:
421
+ res = DatabaseEngine.is_mysql_select_needing_limit(q)
422
+ assert res == expected, f"MySQL LIMIT detection failed for: {q} (expected {expected}, got {res})"
423
+
424
+ print(" [+] MySQL Auto-LIMIT Logic: PASS")
425
+
426
+
427
+ def test_cli_readonly_lock():
428
+ print("[*] 14. Testing CLI --read-only Immutable Lock...")
429
+ from app import GhostBaseApp
430
+ app = GhostBaseApp(db_path=":memory:", read_only=True)
431
+ assert app.safe_mode is True, "Safe mode should be initialized to True"
432
+ assert app._cli_read_only is True, "_cli_read_only flag should be True"
433
+
434
+ # Attempt to toggle safe mode off
435
+ app.action_toggle_safe_mode()
436
+ assert app.safe_mode is True, "VULNERABILITY: CLI --read-only was bypassed via action_toggle_safe_mode!"
437
+
438
+ print(" [+] CLI --read-only Immutable Lock: PASS")
439
+
440
+
305
441
  def main():
306
442
  print("\n" + "=" * 60)
307
- print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
443
+ print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.5)")
308
444
  print("=" * 60)
309
445
 
310
446
  test_safe_mode_and_comment_bypass()
@@ -316,6 +452,11 @@ def main():
316
452
  test_exporter_json_types_and_dialect_sql()
317
453
  test_table_quoting_and_cte_export_detection()
318
454
  test_result_set_memory_limit()
455
+ test_quoted_identifier_safety()
456
+ test_multi_statement_safety()
457
+ test_explain_query_safety()
458
+ test_mysql_auto_limit_logic()
459
+ test_cli_readonly_lock()
319
460
 
320
461
  print("\n" + "=" * 60)
321
462
  print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
File without changes
File without changes
File without changes
File without changes