ghostbase-cli 0.1.1__tar.gz → 0.1.2__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.1
3
+ Version: 0.1.2
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
@@ -342,7 +342,7 @@ class GhostBaseApp(App):
342
342
  yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
343
343
  yield Label(db_label, id="db-name-label")
344
344
  yield Label(safe_text, id="safe-badge", classes=safe_class)
345
- yield Label("v0.1.1", id="version-label")
345
+ yield Label("v0.1.2", id="version-label")
346
346
 
347
347
  with Horizontal(id="main-container"):
348
348
  with Vertical(id="sidebar"):
@@ -389,14 +389,18 @@ class GhostBaseApp(App):
389
389
  # Lifecycle
390
390
 
391
391
  async def on_mount(self):
392
+ # Configure database engine level Read-Only mode if safe_mode is enabled
393
+ if self.safe_mode:
394
+ self.db.set_read_only(True)
395
+
392
396
  tables = self.db.get_tables()
393
- # Only populate demo tables when explicitly requested, in-memory, or default sample.db
397
+ # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
394
398
  is_default_sample = (
395
399
  self.populate_sample
396
400
  or self.db_path == ":memory:"
397
401
  or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
398
402
  )
399
- if not tables and self.db.db_type == "sqlite" and is_default_sample:
403
+ if not tables and self.db.db_type == "sqlite" and is_default_sample and not self.safe_mode:
400
404
  self._create_sample_data()
401
405
  tables = self.db.get_tables()
402
406
 
@@ -408,8 +412,9 @@ class GhostBaseApp(App):
408
412
  if self.all_tables:
409
413
  first = self.all_tables[0]
410
414
  self.selected_table = first
415
+ quoted = self.db.quote_table_name(first)
411
416
  self.query_one("#query-box", TextArea).text = (
412
- f"SELECT * FROM {first} LIMIT 50;"
417
+ f"SELECT * FROM {quoted} LIMIT 50;"
413
418
  )
414
419
  self.action_run_query()
415
420
  self._show_schema(first)
@@ -586,8 +591,9 @@ class GhostBaseApp(App):
586
591
  self._pending_destructive_query = None
587
592
  self._pending_destructive_time = 0.0
588
593
 
594
+ quoted = self.db.quote_table_name(t)
589
595
  self.query_one("#query-box", TextArea).text = (
590
- f"SELECT * FROM {t} LIMIT 50;"
596
+ f"SELECT * FROM {quoted} LIMIT 50;"
591
597
  )
592
598
  self.action_run_query()
593
599
  self._show_schema(t, is_view=is_view)
@@ -673,9 +679,10 @@ class GhostBaseApp(App):
673
679
  self.last_columns = res["columns"]
674
680
  self.last_rows = res["rows"]
675
681
 
676
- # Detect target table for export if simple SELECT FROM table
677
- m = re.search(r"\bFROM\s+[`\"\[]?([a-zA-Z0-9_\.]+)[`\"\]]?", query, flags=re.IGNORECASE)
678
- self.last_query_table = m.group(1).split(".")[-1] if m else (self.selected_table or "query_result")
682
+ # Detect target table for export from final SELECT statement (ignoring CTE definitions)
683
+ main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", query, flags=re.IGNORECASE)
684
+ m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
685
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
679
686
 
680
687
  dt.add_columns(*self.last_columns)
681
688
  for row in self.last_rows:
@@ -702,9 +709,46 @@ class GhostBaseApp(App):
702
709
  else:
703
710
  res_cnt.update(f"({rc} rows)")
704
711
 
712
+ self._update_status_bar(row_count=rc, ms=ms)
713
+ elif res.get("is_returning"):
714
+ # DML with RETURNING: Render returned rows in DataTable, but treat as write operation
715
+ self.last_columns = res["columns"]
716
+ self.last_rows = res["rows"]
717
+
718
+ # Extract target table from INSERT/UPDATE
719
+ m = re.search(r"\b(?:INTO|UPDATE)\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", query, flags=re.IGNORECASE)
720
+ self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
721
+
722
+ dt.add_columns(*self.last_columns)
723
+ for row in self.last_rows:
724
+ styled = []
725
+ for val in row:
726
+ if val is None:
727
+ styled.append(Text("NULL", style="dim #6e7681"))
728
+ elif isinstance(val, (int, float)):
729
+ styled.append(Text(str(val), style="#79c0ff"))
730
+ else:
731
+ s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
732
+ if len(s) > 200:
733
+ s = s[:197] + "..."
734
+ styled.append(Text(s, style="#c9d1d9"))
735
+ dt.add_row(*styled)
736
+
737
+ aff = res.get("affected_rows", len(self.last_rows))
738
+ rc = res.get("row_count", len(self.last_rows))
739
+ ms = res["elapsed_ms"]
740
+ status.update(f"[bold green]OK[/] ({ms}ms)")
741
+ res_cnt.update(f"({rc} rows returned, {aff} affected)")
742
+ self.notify(f"Done — {aff} row(s) affected (RETURNING {rc} rows)")
743
+
744
+ # Refresh schema & row counts
745
+ self.all_tables = self.db.get_tables()
746
+ self.all_views = self.db.get_views()
747
+ self._refresh_row_counts()
748
+ self._refresh_sidebar(self.all_tables, self.all_views)
705
749
  self._update_status_bar(row_count=rc, ms=ms)
706
750
  else:
707
- # Clear export cache so previous SELECT results are not mistakenly exported
751
+ # Pure DML / DDL statement -> clear export cache so previous SELECT results are not mistakenly exported
708
752
  self.last_columns = []
709
753
  self.last_rows = []
710
754
  self.last_query_table = None
@@ -771,6 +815,9 @@ class GhostBaseApp(App):
771
815
  self._pending_destructive_query = None
772
816
  self._pending_destructive_time = 0.0
773
817
 
818
+ # Enforce/revoke database session level Read-Only
819
+ self.db.set_read_only(self.safe_mode)
820
+
774
821
  badge = self.query_one("#safe-badge", Label)
775
822
  if self.safe_mode:
776
823
  badge.update("[SAFE: ON]")
@@ -75,6 +75,38 @@ class DatabaseEngine:
75
75
  autocommit=True,
76
76
  )
77
77
 
78
+ def set_read_only(self, enabled: bool):
79
+ """Configure database engine session-level Read-Only mode."""
80
+ if not self.conn:
81
+ return
82
+ try:
83
+ cur = self.conn.cursor()
84
+ if self.db_type == "sqlite":
85
+ val = "ON" if enabled else "OFF"
86
+ cur.execute(f"PRAGMA query_only = {val};")
87
+ elif self.db_type == "postgresql":
88
+ mode = "ON" if enabled else "OFF"
89
+ cur.execute(f"SET default_transaction_read_only = {mode};")
90
+ elif self.db_type == "mysql":
91
+ mode = "READ ONLY" if enabled else "READ WRITE"
92
+ cur.execute(f"SET SESSION TRANSACTION {mode};")
93
+ except Exception:
94
+ pass
95
+
96
+ def quote_table_name(self, table_name: str) -> str:
97
+ """Properly quote table identifier for queries based on database engine."""
98
+ if not table_name:
99
+ return ""
100
+ if self.db_type == "postgresql":
101
+ if "." in table_name:
102
+ s, t = table_name.split(".", 1)
103
+ return f'"{s}"."{t}"'
104
+ return f'"{table_name}"'
105
+ elif self.db_type == "mysql":
106
+ return f"`{table_name}`"
107
+ else: # sqlite
108
+ return f"[{table_name}]"
109
+
78
110
  # --- Schema discovery ---
79
111
 
80
112
  def get_tables(self) -> List[str]:
@@ -266,16 +298,28 @@ class DatabaseEngine:
266
298
  if self.db_type == "sqlite":
267
299
  cur.execute(f"SELECT COUNT(*) FROM [{table_name}];")
268
300
  elif self.db_type == "postgresql":
269
- clean_name = table_name.split(".", 1)[-1]
301
+ schema = "public"
302
+ tbl = table_name
303
+ if "." in table_name:
304
+ schema, tbl = table_name.split(".", 1)
305
+
270
306
  if fast_estimate:
271
307
  try:
272
- cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = %s;", (clean_name,))
308
+ cur.execute(
309
+ """
310
+ SELECT c.reltuples::bigint
311
+ FROM pg_class c
312
+ JOIN pg_namespace n ON n.oid = c.relnamespace
313
+ WHERE n.nspname = %s AND c.relname = %s;
314
+ """,
315
+ (schema, tbl),
316
+ )
273
317
  r = cur.fetchone()
274
318
  if r and r[0] is not None and r[0] >= 0:
275
319
  return int(r[0])
276
320
  except Exception:
277
321
  pass
278
- cur.execute(f'SELECT COUNT(*) FROM "{table_name}";')
322
+ cur.execute(f'SELECT COUNT(*) FROM "{schema}"."{tbl}";')
279
323
  else:
280
324
  if fast_estimate:
281
325
  try:
@@ -299,20 +343,41 @@ class DatabaseEngine:
299
343
 
300
344
  # --- Query safety and formatting ---
301
345
 
346
+ @staticmethod
347
+ def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
348
+ """
349
+ Extract and mask all string literals and quoted identifiers:
350
+ - PostgreSQL dollar-quoted strings ($$ ... $$ or $tag$ ... $tag$)
351
+ - Standard single-quoted strings ('...')
352
+ - Standard double-quoted strings / identifiers ("...")
353
+ - MySQL backtick identifiers (`...`)
354
+ """
355
+ literals = []
356
+ def _repl(m):
357
+ idx = len(literals)
358
+ literals.append(m.group(0))
359
+ return f" ___GB_LIT_{idx}___ "
360
+
361
+ pattern = r"(\$(?:[a-zA-Z_]\w*)?\$[\s\S]*?\$(?:[a-zA-Z_]\w*)?\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
362
+ masked = re.sub(pattern, _repl, sql)
363
+ return masked, literals
364
+
302
365
  def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
303
366
  """
304
367
  Check query safety.
305
- In Safe Mode: Enforces strict Read-Only operations.
306
- In Normal Mode: Intercepts destructive queries lacking constraints (e.g. DELETE/UPDATE without WHERE).
307
- Comments (-- and /* */) and string literals are stripped to prevent syntax bypasses.
368
+ - Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
369
+ - Comments (-- and /* */) are stripped second.
370
+ - In Safe Mode: Enforces strict Read-Only operations.
371
+ - In Normal Mode: Intercepts unconstrained destructive queries.
308
372
  """
309
- # 1. Strip block comments /* ... */ and line comments -- ...
310
- q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", query)
373
+ # 1. Mask all string literals and dollar-quoted strings FIRST
374
+ masked, _ = self._mask_strings_and_literals(query)
375
+
376
+ # 2. Strip block comments /* ... */ and line comments -- ...
377
+ q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
311
378
  q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
312
379
 
313
- # 2. Strip string literals & identifiers so keywords inside literals don't cause false detections
314
- q_clean = re.sub(r"('(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)", " ", q_no_comments)
315
- q_clean = q_clean.strip()
380
+ q_clean = q_no_comments.strip()
316
381
  if not q_clean:
317
382
  return False, ""
318
383
 
@@ -332,6 +397,9 @@ class DatabaseEngine:
332
397
  (r"\bGRANT\b", "GRANT statement"),
333
398
  (r"\bREVOKE\b", "REVOKE statement"),
334
399
  (r"\bVACUUM\b", "VACUUM statement"),
400
+ (r"\bNEXTVAL\s*\(", "NEXTVAL sequence advance"),
401
+ (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
402
+ (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
335
403
  ]
336
404
  for pattern, desc in write_keywords:
337
405
  if re.search(pattern, q_upper):
@@ -357,8 +425,12 @@ class DatabaseEngine:
357
425
  return False, ""
358
426
 
359
427
  def explain_query(self, query: str) -> Dict[str, Any]:
360
- clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", query)
428
+ masked, literals = self._mask_strings_and_literals(query)
429
+ clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
361
430
  clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().rstrip(";")
431
+ for idx, orig in enumerate(literals):
432
+ clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
433
+
362
434
  if not clean_q:
363
435
  return {"success": False, "error": "Query is empty"}
364
436
 
@@ -374,15 +446,7 @@ class DatabaseEngine:
374
446
  if not query or not query.strip():
375
447
  return query
376
448
 
377
- # Mask string literals ('...'), double-quoted strings ("..."), and backticks (`...`)
378
- literals = []
379
- def _mask_literal(m):
380
- idx = len(literals)
381
- literals.append(m.group(0))
382
- return f"___GB_LIT_{idx}___"
383
-
384
- lit_pattern = r"('(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
385
- masked = re.sub(lit_pattern, _mask_literal, query)
449
+ masked, literals = DatabaseEngine._mask_strings_and_literals(query)
386
450
 
387
451
  lines = masked.splitlines()
388
452
  comment_lines = []
@@ -441,6 +505,14 @@ class DatabaseEngine:
441
505
  def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
442
506
  start = time.perf_counter()
443
507
  try:
508
+ # Check if query is DML/DDL write operation (even if it has RETURNING)
509
+ masked, _ = self._mask_strings_and_literals(query)
510
+ clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
511
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
512
+ is_write_dml = bool(
513
+ re.search(r"\b(INSERT\s+INTO|UPDATE\s+|DELETE\s+FROM|DELETE\s+|REPLACE\s+INTO|CREATE\s+|DROP\s+|ALTER\s+|TRUNCATE\s+)\b", clean_q)
514
+ )
515
+
444
516
  cur = self.conn.cursor()
445
517
  cur.execute(query)
446
518
  elapsed_ms = (time.perf_counter() - start) * 1000
@@ -469,9 +541,11 @@ class DatabaseEngine:
469
541
  "columns": columns,
470
542
  "rows": rows,
471
543
  "row_count": len(rows),
544
+ "affected_rows": max(cur.rowcount, len(rows)) if is_write_dml else 0,
472
545
  "truncated": truncated,
473
546
  "elapsed_ms": round(elapsed_ms, 2),
474
- "is_select": True,
547
+ "is_select": not is_write_dml,
548
+ "is_returning": is_write_dml,
475
549
  }
476
550
  else:
477
551
  if self.db_type in ("sqlite", "postgresql"):
@@ -481,6 +555,7 @@ class DatabaseEngine:
481
555
  "affected_rows": max(cur.rowcount, 0),
482
556
  "elapsed_ms": round(elapsed_ms, 2),
483
557
  "is_select": False,
558
+ "is_returning": False,
484
559
  }
485
560
  except Exception as e:
486
561
  elapsed_ms = (time.perf_counter() - start) * 1000
@@ -1,9 +1,30 @@
1
1
  import csv
2
2
  import json
3
+ import decimal
4
+ import datetime
5
+ import uuid
3
6
  from typing import List, Any
4
7
 
5
8
 
6
9
  class Exporter:
10
+ @staticmethod
11
+ def _json_serial(obj: Any) -> Any:
12
+ """Serialize non-standard types (Decimal, datetime, UUID, bytes) for JSON export."""
13
+ if isinstance(obj, (datetime.date, datetime.datetime, datetime.time)):
14
+ return obj.isoformat()
15
+ if isinstance(obj, decimal.Decimal):
16
+ return int(obj) if (obj % 1 == 0) else float(obj)
17
+ if isinstance(obj, uuid.UUID):
18
+ return str(obj)
19
+ if isinstance(obj, (bytes, bytearray)):
20
+ try:
21
+ return obj.decode("utf-8")
22
+ except Exception:
23
+ return obj.hex()
24
+ if hasattr(obj, "__str__"):
25
+ return str(obj)
26
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
27
+
7
28
  @staticmethod
8
29
  def _dedup_columns(columns: List[str]) -> List[str]:
9
30
  seen = {}
@@ -21,6 +42,10 @@ class Exporter:
21
42
  @staticmethod
22
43
  def _escape_ident(ident: str) -> str:
23
44
  s = str(ident).strip()
45
+ if "." in s:
46
+ parts = s.split(".")
47
+ return ".".join(f'"{p.replace(chr(34), chr(34)*2)}"' for p in parts)
48
+
24
49
  reserved = {
25
50
  "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE",
26
51
  "CHECK", "COLUMN", "CONSTRAINT", "CREATE", "CROSS", "CURRENT_DATE",
@@ -52,7 +77,7 @@ class Exporter:
52
77
  for r in rows:
53
78
  data.append(dict(zip(clean_cols, r)))
54
79
  with open(output_path, "w", encoding="utf-8") as f:
55
- json.dump(data, f, indent=2, ensure_ascii=False)
80
+ json.dump(data, f, indent=2, ensure_ascii=False, default=Exporter._json_serial)
56
81
 
57
82
  @staticmethod
58
83
  def to_markdown(columns: List[str], rows: List[List[Any]], output_path: str):
@@ -85,7 +110,7 @@ class Exporter:
85
110
  vals.append("NULL")
86
111
  elif isinstance(v, bool):
87
112
  vals.append("TRUE" if v else "FALSE")
88
- elif isinstance(v, (int, float)):
113
+ elif isinstance(v, (int, float, decimal.Decimal)):
89
114
  vals.append(str(v))
90
115
  else:
91
116
  escaped = str(v).replace("'", "''")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghostbase-cli"
7
- version = "0.1.1"
7
+ version = "0.1.2"
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" }]
@@ -0,0 +1,288 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ import sqlite3
5
+ import json
6
+ import decimal
7
+ import datetime
8
+ import uuid
9
+ import re
10
+
11
+ # Ensure parent directory is in path
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from db_engine import DatabaseEngine
15
+ from exporter import Exporter
16
+ from main import detect_connection
17
+
18
+
19
+ def test_safe_mode_and_comment_bypass():
20
+ print("[*] 1. Testing Safe Mode & Comment Bypass Prevention...")
21
+ db = DatabaseEngine(":memory:")
22
+
23
+ # Comment bypass attempt: WHERE is inside /* ... */ block comment
24
+ bypass_query = "UPDATE users SET role = 'Member' /* WHERE id = 1 */;"
25
+ is_destructive, reason = db.check_safety(bypass_query, is_safe_mode=False)
26
+ assert is_destructive, "VULNERABILITY: Block comment containing WHERE fooled check_safety!"
27
+ assert "UPDATE without WHERE" in reason
28
+
29
+ # Line comment bypass attempt: WHERE is after --
30
+ bypass_line = "UPDATE users SET role = 'Member' -- WHERE id = 1\n;"
31
+ is_destructive, reason = db.check_safety(bypass_line, is_safe_mode=False)
32
+ assert is_destructive, "VULNERABILITY: Line comment containing WHERE fooled check_safety!"
33
+
34
+ # String with WHERE inside must not count as WHERE clause
35
+ string_bypass = "UPDATE users SET role = 'WHERE -- this is a string';"
36
+ is_dest, r = db.check_safety(string_bypass, is_safe_mode=False)
37
+ assert is_dest, f"VULNERABILITY: String literal containing WHERE fooled check_safety: {r}"
38
+
39
+ # Postgres dollar-quoted string with WHERE
40
+ dollar_bypass = "UPDATE users SET role = $$WHERE$$;"
41
+ is_dest, r = db.check_safety(dollar_bypass, is_safe_mode=False)
42
+ assert is_dest, f"VULNERABILITY: Dollar-quoted string containing WHERE fooled check_safety: {r}"
43
+
44
+ # Safe Mode STRICT read-only checks
45
+ safe_queries = [
46
+ "SELECT * FROM users WHERE id = 5;",
47
+ "SELECT 'DROP TABLE users' AS msg;",
48
+ "WITH cte AS (SELECT 1 AS n) SELECT * FROM cte;",
49
+ "EXPLAIN SELECT * FROM users;",
50
+ ]
51
+ for q in safe_queries:
52
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
53
+ assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
54
+
55
+ unsafe_queries = [
56
+ ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
57
+ ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
58
+ ("DELETE FROM users WHERE id = 5;", "DELETE"),
59
+ ("DROP TABLE users;", "DROP"),
60
+ ("TRUNCATE TABLE users;", "TRUNCATE"),
61
+ ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
62
+ ("CREATE TABLE test (id INT);", "CREATE"),
63
+ ("SELECT nextval('users_id_seq');", "NEXTVAL"),
64
+ ("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
65
+ ]
66
+ for q, op in unsafe_queries:
67
+ is_dest, r = db.check_safety(q, is_safe_mode=True)
68
+ assert is_dest, f"VULNERABILITY: Safe Mode failed to block {op}: {q}"
69
+ assert "Safe Mode is active" in r
70
+
71
+ print(" [+] Safe Mode & Comment Bypass: PASS")
72
+
73
+
74
+ def test_database_engine_level_readonly():
75
+ print("[*] 2. Testing Database Engine-Level Read-Only Enforcement...")
76
+ db = DatabaseEngine(":memory:")
77
+ db.conn.execute("CREATE TABLE read_only_test (id INT, val TEXT);")
78
+ db.conn.commit()
79
+
80
+ # Enable engine-level read-only
81
+ db.set_read_only(True)
82
+
83
+ # SQLite PRAGMA query_only=ON must reject writes at the C/engine level
84
+ failed = False
85
+ try:
86
+ db.conn.execute("INSERT INTO read_only_test VALUES (1, 'fail');")
87
+ db.conn.commit()
88
+ except sqlite3.OperationalError as e:
89
+ if "readonly" in str(e).lower() or "query_only" in str(e).lower():
90
+ failed = True
91
+
92
+ assert failed, "VULNERABILITY: SQLite allowed write with PRAGMA query_only = ON!"
93
+
94
+ # Disable read-only
95
+ db.set_read_only(False)
96
+ db.conn.execute("INSERT INTO read_only_test VALUES (1, 'success');")
97
+ db.conn.commit()
98
+
99
+ print(" [+] Database Engine-Level Read-Only: PASS")
100
+
101
+
102
+ def test_sql_beautifier_preserves_literals():
103
+ print("[*] 3. Testing SQL Beautifier Literal Integrity (Single, Double & Dollar Quotes)...")
104
+ raw_query = "SELECT 'order by and where' AS message, $$order by AND where$$ AS dollar_msg FROM users WHERE id = 1;"
105
+ formatted = DatabaseEngine.format_sql(raw_query)
106
+
107
+ assert "'order by and where'" in formatted, f"VULNERABILITY: Beautifier corrupted single-quoted literal: {formatted}"
108
+ assert "$$order by AND where$$" in formatted, f"VULNERABILITY: Beautifier corrupted dollar-quoted literal: {formatted}"
109
+ assert "WHERE" in formatted
110
+ assert "SELECT" in formatted
111
+
112
+ print(" [+] SQL Beautifier Preserves Literals: PASS")
113
+
114
+
115
+ def test_sqlite_foreign_keys_enforced():
116
+ print("[*] 4. Testing SQLite Foreign Keys Enforcement...")
117
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
118
+ db_path = f.name
119
+
120
+ try:
121
+ db = DatabaseEngine(db_path)
122
+ cur = db.conn.cursor()
123
+ cur.execute("PRAGMA foreign_keys;")
124
+ fk_status = cur.fetchone()[0]
125
+ assert fk_status == 1, f"VULNERABILITY: PRAGMA foreign_keys is OFF ({fk_status})"
126
+
127
+ db.conn.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT);")
128
+ db.conn.execute("CREATE TABLE emp (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES dept(id));")
129
+ db.conn.commit()
130
+
131
+ failed = False
132
+ try:
133
+ db.conn.execute("INSERT INTO emp (id, dept_id) VALUES (1, 999);")
134
+ db.conn.commit()
135
+ except sqlite3.IntegrityError:
136
+ failed = True
137
+
138
+ assert failed, "VULNERABILITY: SQLite allowed foreign key violation without error!"
139
+ finally:
140
+ db.close()
141
+ if os.path.exists(db_path):
142
+ os.remove(db_path)
143
+
144
+ print(" [+] SQLite Foreign Key Enforcement: PASS")
145
+
146
+
147
+ def test_empty_sqlite_not_polluted():
148
+ print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
149
+ from app import GhostBaseApp
150
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
151
+ empty_db_path = f.name
152
+
153
+ try:
154
+ # Launch in read-only / safe mode
155
+ app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
156
+ tables = app.db.get_tables()
157
+ assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
158
+ app.db.close()
159
+ finally:
160
+ if os.path.exists(empty_db_path):
161
+ os.remove(empty_db_path)
162
+
163
+ print(" [+] Empty Database Integrity in Read-Only: PASS")
164
+
165
+
166
+ def test_returning_dml_classification():
167
+ print("[*] 6. Testing DML with RETURNING Classification...")
168
+ db = DatabaseEngine(":memory:")
169
+ db.conn.execute("CREATE TABLE ret_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);")
170
+ db.conn.commit()
171
+
172
+ res = db.execute_query("INSERT INTO ret_test (name) VALUES ('Item1') RETURNING id, name;")
173
+ assert res["success"]
174
+ assert res["is_returning"] is True, "Expected is_returning to be True"
175
+ assert res["is_select"] is False, "Expected is_select to be False for INSERT RETURNING"
176
+ assert res["affected_rows"] == 1
177
+ assert len(res["rows"]) == 1
178
+ assert res["rows"][0][1] == "Item1"
179
+
180
+ print(" [+] DML with RETURNING Classification: PASS")
181
+
182
+
183
+ def test_exporter_dedup_and_ident_escaping():
184
+ print("[*] 7. Testing Exporter JSON Types & SQL Identifier Escaping...")
185
+ tmp = tempfile.gettempdir()
186
+ json_path = os.path.join(tmp, "test_types.json")
187
+ sql_path = os.path.join(tmp, "test_escape.sql")
188
+
189
+ # JSON export with Decimal, datetime, UUID, bytes, and duplicate column names
190
+ cols = ["id", "id", "price", "created", "uid", "raw"]
191
+ rows = [[
192
+ 1, 1,
193
+ decimal.Decimal("49.95"),
194
+ datetime.datetime(2026, 11, 2, 8, 0, 0),
195
+ uuid.UUID("12345678-1234-5678-1234-567812345678"),
196
+ b"binary_ghost"
197
+ ]]
198
+ Exporter.to_json(cols, rows, json_path)
199
+ with open(json_path, "r", encoding="utf-8") as f:
200
+ data = json.load(f)
201
+ assert len(data) == 1
202
+ assert data[0]["id"] == 1
203
+ assert data[0]["id_2"] == 1
204
+ assert data[0]["price"] == 49.95
205
+ assert data[0]["created"] == "2026-11-02T08:00:00"
206
+ assert data[0]["uid"] == "12345678-1234-5678-1234-567812345678"
207
+ assert data[0]["raw"] == "binary_ghost"
208
+
209
+ # SQL export schema-qualified tables & reserved words
210
+ table_name = "analytics.order"
211
+ cols_special = ["first name", "group", "price"]
212
+ rows_special = [["Bob", "Admin", decimal.Decimal("10.50")]]
213
+ Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_path)
214
+ with open(sql_path, "r", encoding="utf-8") as f:
215
+ sql_content = f.read()
216
+
217
+ assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in sql_content
218
+
219
+ print(" [+] Exporter JSON Types & SQL Escaping: PASS")
220
+
221
+
222
+ def test_table_quoting_and_cte_export_detection():
223
+ print("[*] 8. Testing Table Quoting & CTE Export Target Detection...")
224
+ db_sqlite = DatabaseEngine(":memory:")
225
+ assert db_sqlite.quote_table_name("order") == "[order]"
226
+
227
+ db_pg = DatabaseEngine.__new__(DatabaseEngine)
228
+ db_pg.db_type = "postgresql"
229
+ assert db_pg.quote_table_name("order") == '"order"'
230
+ assert db_pg.quote_table_name("analytics.users") == '"analytics"."users"'
231
+
232
+ db_my = DatabaseEngine.__new__(DatabaseEngine)
233
+ db_my.db_type = "mysql"
234
+ assert db_my.quote_table_name("order") == "`order`"
235
+
236
+ # CTE target detection test
237
+ cte_query = """
238
+ WITH recent AS (
239
+ SELECT * FROM archived_users WHERE active = 0
240
+ )
241
+ SELECT * FROM recent;
242
+ """
243
+ main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", cte_query.strip(), flags=re.IGNORECASE)
244
+ m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
245
+ assert m is not None
246
+ detected = m.group(1).strip('`"[]')
247
+ assert detected == "recent", f"Expected target 'recent', got '{detected}'"
248
+
249
+ print(" [+] Table Quoting & CTE Export Target Detection: PASS")
250
+
251
+
252
+ def test_result_set_memory_limit():
253
+ print("[*] 9. Testing Result Set Memory Limit (Capped at 1,000 rows)...")
254
+ db = DatabaseEngine(":memory:")
255
+ db.conn.execute("CREATE TABLE big (id INTEGER PRIMARY KEY);")
256
+ db.conn.executemany("INSERT INTO big (id) VALUES (?);", [(i,) for i in range(1500)])
257
+ db.conn.commit()
258
+
259
+ res = db.execute_query("SELECT * FROM big;", max_rows=1000)
260
+ assert res["success"]
261
+ assert res["row_count"] == 1000, f"Expected 1000 rows capped, got {res['row_count']}"
262
+ assert res["truncated"] is True, "Expected truncated flag to be True"
263
+
264
+ print(" [+] Result Set Memory Limit: PASS")
265
+
266
+
267
+ def main():
268
+ print("\n" + "=" * 60)
269
+ print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.2)")
270
+ print("=" * 60)
271
+
272
+ test_safe_mode_and_comment_bypass()
273
+ test_database_engine_level_readonly()
274
+ test_sql_beautifier_preserves_literals()
275
+ test_sqlite_foreign_keys_enforced()
276
+ test_empty_sqlite_not_polluted()
277
+ test_returning_dml_classification()
278
+ test_exporter_dedup_and_ident_escaping()
279
+ test_table_quoting_and_cte_export_detection()
280
+ test_result_set_memory_limit()
281
+
282
+ print("\n" + "=" * 60)
283
+ print("ALL 9 CRITICAL & HIGH EDGE CASES VERIFIED AND 100% PASSING!")
284
+ print("=" * 60 + "\n")
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -1,216 +0,0 @@
1
- import os
2
- import sys
3
- import tempfile
4
- import sqlite3
5
- import json
6
-
7
- # Ensure parent directory is in path
8
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
9
-
10
- from db_engine import DatabaseEngine
11
- from exporter import Exporter
12
- from main import detect_connection
13
-
14
-
15
- def test_safe_mode_and_comment_bypass():
16
- print("[*] 1. Testing Safe Mode & Comment Bypass Prevention...")
17
- db = DatabaseEngine(":memory:")
18
-
19
- # Comment bypass attempt: WHERE is inside /* ... */ block comment
20
- bypass_query = "UPDATE users SET role = 'Member' /* WHERE id = 1 */;"
21
- is_destructive, reason = db.check_safety(bypass_query, is_safe_mode=False)
22
- assert is_destructive, "VULNERABILITY: Block comment containing WHERE fooled check_safety!"
23
- assert "UPDATE without WHERE" in reason
24
-
25
- # Line comment bypass attempt: WHERE is after --
26
- bypass_line = "UPDATE users SET role = 'Member' -- WHERE id = 1\n;"
27
- is_destructive, reason = db.check_safety(bypass_line, is_safe_mode=False)
28
- assert is_destructive, "VULNERABILITY: Line comment containing WHERE fooled check_safety!"
29
-
30
- # Safe Mode STRICT read-only checks
31
- safe_queries = [
32
- "SELECT * FROM users WHERE id = 5;",
33
- "SELECT 'DROP TABLE users' AS msg;",
34
- "WITH cte AS (SELECT 1 AS n) SELECT * FROM cte;",
35
- "EXPLAIN SELECT * FROM users;",
36
- ]
37
- for q in safe_queries:
38
- is_dest, r = db.check_safety(q, is_safe_mode=True)
39
- assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
40
-
41
- unsafe_queries = [
42
- ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
43
- ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
44
- ("DELETE FROM users WHERE id = 5;", "DELETE"),
45
- ("DROP TABLE users;", "DROP"),
46
- ("TRUNCATE TABLE users;", "TRUNCATE"),
47
- ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
48
- ("CREATE TABLE test (id INT);", "CREATE"),
49
- ]
50
- for q, op in unsafe_queries:
51
- is_dest, r = db.check_safety(q, is_safe_mode=True)
52
- assert is_dest, f"VULNERABILITY: Safe Mode failed to block {op}: {q}"
53
- assert "Safe Mode is active" in r
54
-
55
- print(" [+] Safe Mode & Comment Bypass: PASS")
56
-
57
-
58
- def test_sql_beautifier_preserves_literals():
59
- print("[*] 2. Testing SQL Beautifier Literal Integrity...")
60
- raw_query = "SELECT 'order by and where' AS message, 'SELECT * FROM users' AS sample FROM users WHERE id = 1;"
61
- formatted = DatabaseEngine.format_sql(raw_query)
62
-
63
- # The string literals must not have their contents altered to uppercase or split by newlines
64
- assert "'order by and where'" in formatted, f"VULNERABILITY: Beautifier corrupted string literal! Got: {formatted}"
65
- assert "'SELECT * FROM users'" in formatted, "VULNERABILITY: Beautifier corrupted inner SQL string!"
66
- # The actual SQL keyword outside strings should be formatted
67
- assert "WHERE" in formatted
68
- assert "SELECT" in formatted
69
-
70
- print(" [+] SQL Beautifier Preserves Literals: PASS")
71
-
72
-
73
- def test_sqlite_foreign_keys_enforced():
74
- print("[*] 3. Testing SQLite Foreign Keys Enforcement...")
75
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
76
- db_path = f.name
77
-
78
- try:
79
- db = DatabaseEngine(db_path)
80
- # Verify PRAGMA foreign_keys is ON
81
- cur = db.conn.cursor()
82
- cur.execute("PRAGMA foreign_keys;")
83
- fk_status = cur.fetchone()[0]
84
- assert fk_status == 1, f"VULNERABILITY: PRAGMA foreign_keys is OFF ({fk_status})"
85
-
86
- # Verify real constraint enforcement
87
- db.conn.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT);")
88
- db.conn.execute("CREATE TABLE emp (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES dept(id));")
89
- db.conn.commit()
90
-
91
- # Inserting child with invalid foreign key must raise IntegrityError
92
- failed = False
93
- try:
94
- db.conn.execute("INSERT INTO emp (id, dept_id) VALUES (1, 999);")
95
- db.conn.commit()
96
- except sqlite3.IntegrityError:
97
- failed = True
98
-
99
- assert failed, "VULNERABILITY: SQLite allowed foreign key violation without error!"
100
- finally:
101
- db.close()
102
- if os.path.exists(db_path):
103
- os.remove(db_path)
104
-
105
- print(" [+] SQLite Foreign Key Enforcement: PASS")
106
-
107
-
108
- def test_empty_sqlite_not_polluted():
109
- print("[*] 4. Testing Empty SQLite Database Guard...")
110
- from app import GhostBaseApp
111
- with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
112
- empty_db_path = f.name
113
-
114
- try:
115
- # User opens an empty database without populate_sample flag
116
- app = GhostBaseApp(db_path=empty_db_path, populate_sample=False)
117
- tables = app.db.get_tables()
118
- assert len(tables) == 0, f"VULNERABILITY: Sample tables were automatically added to user's database: {tables}"
119
- app.db.close()
120
- finally:
121
- if os.path.exists(empty_db_path):
122
- os.remove(empty_db_path)
123
-
124
- print(" [+] Empty Database Integrity: PASS")
125
-
126
-
127
- def test_exporter_dedup_and_ident_escaping():
128
- print("[*] 5. Testing Exporter Duplicate Column Dedup & Quoting...")
129
- tmp = tempfile.gettempdir()
130
- json_path = os.path.join(tmp, "test_dedup.json")
131
- sql_path = os.path.join(tmp, "test_escape.sql")
132
-
133
- # JSON duplicate columns
134
- cols = ["id", "id", "name"]
135
- rows = [[1, 2, "Alice"]]
136
- Exporter.to_json(cols, rows, json_path)
137
- with open(json_path, "r", encoding="utf-8") as f:
138
- data = json.load(f)
139
- assert len(data) == 1
140
- assert data[0]["id"] == 1
141
- assert data[0]["id_2"] == 2
142
- assert data[0]["name"] == "Alice"
143
-
144
- # SQL export reserved keywords & special characters
145
- table_name = "order" # Reserved word
146
- cols_with_special = ["first name", "group", "price"] # "first name" has space, "group" is reserved
147
- rows_special = [[ "Bob", "Admin", 99.5 ]]
148
- Exporter.to_sql_inserts(table_name, cols_with_special, rows_special, sql_path)
149
- with open(sql_path, "r", encoding="utf-8") as f:
150
- sql_content = f.read()
151
-
152
- assert 'INSERT INTO "order" ("first name", "group", price)' in sql_content
153
-
154
- print(" [+] Exporter Dedup & Escaping: PASS")
155
-
156
-
157
- def test_result_set_memory_limit():
158
- print("[*] 6. Testing Result Set Memory Limit (Capped at 1,000 rows)...")
159
- db = DatabaseEngine(":memory:")
160
- db.conn.execute("CREATE TABLE big (id INTEGER PRIMARY KEY);")
161
- db.conn.executemany("INSERT INTO big (id) VALUES (?);", [(i,) for i in range(1500)])
162
- db.conn.commit()
163
-
164
- res = db.execute_query("SELECT * FROM big;", max_rows=1000)
165
- assert res["success"]
166
- assert res["row_count"] == 1000, f"Expected 1000 rows capped, got {res['row_count']}"
167
- assert res["truncated"] is True, "Expected truncated flag to be True"
168
-
169
- print(" [+] Result Set Memory Limit: PASS")
170
-
171
-
172
- def test_env_credentials_url_encoding():
173
- print("[*] 7. Testing .env Credentials URL Encoding...")
174
- tmp_env = os.path.join(tempfile.gettempdir(), ".env")
175
- with open(tmp_env, "w", encoding="utf-8") as f:
176
- f.write("DB_HOST=127.0.0.1\n")
177
- f.write("DB_PORT=5432\n")
178
- f.write("DB_NAME=my_db\n")
179
- f.write("DB_USER=user@work#1\n")
180
- f.write("DB_PASSWORD=p@ss:w/ord%!\n")
181
-
182
- # Backup current dir and test detect_connection
183
- orig_dir = os.getcwd()
184
- os.chdir(tempfile.gettempdir())
185
- try:
186
- url = detect_connection()
187
- assert "user%40work%231" in url
188
- assert "p%40ss%3Aw%2Ford%25%21" in url
189
- finally:
190
- os.chdir(orig_dir)
191
- if os.path.exists(tmp_env):
192
- os.remove(tmp_env)
193
-
194
- print(" [+] .env Credentials URL Encoding: PASS")
195
-
196
-
197
- def main():
198
- print("\n" + "=" * 60)
199
- print("RUNNING SECURITY & BUG FIX VERIFICATION TESTS")
200
- print("=" * 60)
201
-
202
- test_safe_mode_and_comment_bypass()
203
- test_sql_beautifier_preserves_literals()
204
- test_sqlite_foreign_keys_enforced()
205
- test_empty_sqlite_not_polluted()
206
- test_exporter_dedup_and_ident_escaping()
207
- test_result_set_memory_limit()
208
- test_env_credentials_url_encoding()
209
-
210
- print("\n" + "=" * 60)
211
- print("ALL 10 ANALYST FINDINGS & VULNERABILITIES ARE 100% FIXED!")
212
- print("=" * 60 + "\n")
213
-
214
-
215
- if __name__ == "__main__":
216
- main()
File without changes
File without changes
File without changes
File without changes