ghostbase-cli 0.1.3__tar.gz → 0.1.4__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.3
3
+ Version: 0.1.4
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.3", id="version-label")
345
+ yield Label("v0.1.4", id="version-label")
346
346
 
347
347
  with Horizontal(id="main-container"):
348
348
  with Vertical(id="sidebar"):
@@ -389,13 +389,14 @@ 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 (fail-closed)
392
+ # Configure database engine level Read-Only mode if safe_mode is enabled (strictly fail-closed)
393
393
  if self.safe_mode:
394
394
  try:
395
395
  self.db.set_read_only(True)
396
396
  except Exception as e:
397
- self.safe_mode = False
398
- self.notify(f"Failed to enable Read-Only mode on database: {e}", severity="error")
397
+ self.notify(f"Security Alert: Failed to enforce Read-Only mode on database: {e}", severity="error")
398
+ self.exit(return_code=1)
399
+ return
399
400
 
400
401
  tables = self.db.get_tables()
401
402
  # Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
@@ -52,7 +52,7 @@ class DatabaseEngine:
52
52
  "Run: pip install psycopg2-binary"
53
53
  )
54
54
  self.conn = psycopg2.connect(url)
55
- # Enable autocommit so transactions are not left IDLE and RETURNING queries commit immediately
55
+ # Enable autocommit so write transactions are not left IDLE and RETURNING queries commit immediately
56
56
  self.conn.autocommit = True
57
57
 
58
58
  def _connect_mysql(self, url: str):
@@ -357,8 +357,10 @@ class DatabaseEngine:
357
357
  def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
358
358
  """
359
359
  Extract and mask all string literals and quoted identifiers:
360
- - PostgreSQL dollar-quoted strings with exact tag matching: $tag$ ... $tag$ (using backreference \2)
361
- - Standard single-quoted strings: '...' (with '' escaping)
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 '')
362
364
  - Standard double-quoted strings / identifiers: "..."
363
365
  - MySQL backtick identifiers: `...`
364
366
  """
@@ -368,13 +370,7 @@ class DatabaseEngine:
368
370
  literals.append(m.group(0))
369
371
  return f" ___GB_LIT_{idx}___ "
370
372
 
371
- # Matches:
372
- # 1. Plain untagged dollar-quotes: $$...$$
373
- # 2. Tagged dollar-quotes with strict matching backreference: $tag$...$tag$
374
- # 3. Standard single-quoted strings: '...'
375
- # 4. Standard double-quoted strings/identifiers: "..."
376
- # 5. MySQL backticks: `...`
377
- pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
373
+ pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`)"
378
374
  masked = re.sub(pattern, _repl, sql)
379
375
  return masked, literals
380
376
 
@@ -382,16 +378,17 @@ class DatabaseEngine:
382
378
  """
383
379
  Check query safety.
384
380
  - Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
385
- - Comments (-- and /* */) are stripped second.
386
- - In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL, and SET configuration changes.
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.
387
383
  - In Normal Mode: Intercepts unconstrained destructive queries.
388
384
  """
389
385
  # 1. Mask all string literals and dollar-quoted strings FIRST
390
386
  masked, _ = self._mask_strings_and_literals(query)
391
387
 
392
- # 2. Strip block comments /* ... */ and line comments -- ...
388
+ # 2. Strip block comments /* ... */, line comments -- ..., and MySQL # ...
393
389
  q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
394
390
  q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
391
+ q_no_comments = re.sub(r"#[^\r\n]*", " ", q_no_comments)
395
392
 
396
393
  q_clean = q_no_comments.strip()
397
394
  if not q_clean:
@@ -413,14 +410,23 @@ class DatabaseEngine:
413
410
  (r"\bGRANT\b", "GRANT statement"),
414
411
  (r"\bREVOKE\b", "REVOKE statement"),
415
412
  (r"\bVACUUM\b", "VACUUM statement"),
416
- (r"\bNEXTVAL\s*\(", "NEXTVAL sequence advance"),
417
- (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
418
- (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
419
413
  (r"\bSET\b", "SET configuration change"),
420
414
  (r"\bRESET\b", "RESET configuration change"),
421
415
  (r"\bDO\b", "DO procedural block"),
422
416
  (r"\bCALL\b", "CALL stored procedure"),
423
417
  (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"),
423
+ (r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
424
+ (r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
425
+ (r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
426
+ (r"\bLO_EXPORT\b", "LO_EXPORT filesystem write"),
427
+ (r"\bDBLINK\b", "DBLINK external query execution"),
428
+ (r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
429
+ (r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
424
430
  ]
425
431
  for pattern, desc in write_keywords:
426
432
  if re.search(pattern, q_upper):
@@ -448,7 +454,8 @@ class DatabaseEngine:
448
454
  def explain_query(self, query: str) -> Dict[str, Any]:
449
455
  masked, literals = self._mask_strings_and_literals(query)
450
456
  clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
451
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().rstrip(";")
457
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
458
+ clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().rstrip(";")
452
459
  for idx, orig in enumerate(literals):
453
460
  clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
454
461
 
@@ -474,7 +481,7 @@ class DatabaseEngine:
474
481
  sql_lines = []
475
482
  for line in lines:
476
483
  stripped = line.strip()
477
- if stripped.startswith("--"):
484
+ if stripped.startswith(("--", "#")):
478
485
  comment_lines.append(stripped)
479
486
  else:
480
487
  sql_lines.append(line)
@@ -530,23 +537,58 @@ class DatabaseEngine:
530
537
  # Check if query is DML/DDL write operation (even if it has RETURNING)
531
538
  masked, _ = self._mask_strings_and_literals(query)
532
539
  clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
533
- clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
540
+ clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
541
+ clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().upper()
534
542
  is_write_dml = bool(
535
543
  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+|DO\s+|CALL\s+|MERGE\s+)\b", clean_q)
536
544
  )
537
545
 
538
- # Use server-side streaming cursor for PostgreSQL read queries to prevent client RAM overflow
546
+ # PostgreSQL streaming: Use server-side named cursor to prevent pulling entire table into client RAM
539
547
  if self.db_type == "postgresql" and not is_write_dml:
540
548
  stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
549
+ # Disable autocommit temporarily for named cursor creation in psycopg2
550
+ self.conn.autocommit = False
541
551
  try:
542
552
  cur = self.conn.cursor(name=stream_name)
543
553
  cur.itersize = max_rows + 1
544
- except Exception:
545
- cur = self.conn.cursor()
546
- else:
547
- cur = self.conn.cursor()
554
+ cur.execute(query)
555
+ if cur.description:
556
+ columns = [d[0] for d in cur.description]
557
+ raw = cur.fetchmany(max_rows + 1)
558
+ truncated = len(raw) > max_rows
559
+ display_rows = raw[:max_rows]
560
+ rows = [list(r.values()) if isinstance(r, dict) else list(r) for r in display_rows]
561
+ cur.close()
562
+ self.conn.commit() # Cleanly close read transaction without leaving idle connection
563
+ elapsed_ms = (time.perf_counter() - start) * 1000
564
+ return {
565
+ "success": True,
566
+ "columns": columns,
567
+ "rows": rows,
568
+ "row_count": len(rows),
569
+ "affected_rows": 0,
570
+ "truncated": truncated,
571
+ "elapsed_ms": round(elapsed_ms, 2),
572
+ "is_select": True,
573
+ "is_returning": False,
574
+ }
575
+ except Exception as e:
576
+ try:
577
+ self.conn.rollback()
578
+ except Exception:
579
+ pass
580
+ raise e
581
+ finally:
582
+ self.conn.autocommit = True
583
+
584
+ # MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
585
+ exec_query = query
586
+ if self.db_type == "mysql" and not is_write_dml:
587
+ if not re.search(r"\bLIMIT\b", clean_q, flags=re.IGNORECASE):
588
+ exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
548
589
 
549
- cur.execute(query)
590
+ cur = self.conn.cursor()
591
+ cur.execute(exec_query)
550
592
  elapsed_ms = (time.perf_counter() - start) * 1000
551
593
 
552
594
  if cur.description:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbase-cli
3
- Version: 0.1.3
3
+ Version: 0.1.4
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.3"
7
+ version = "0.1.4"
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" }]
@@ -46,6 +46,16 @@ def test_safe_mode_and_comment_bypass():
46
46
  is_dest, r = db.check_safety(mismatched_tag_query, is_safe_mode=False)
47
47
  assert is_dest, f"VULNERABILITY: Dollar-quoted tag mismatch bypassed WHERE check: {r}"
48
48
 
49
+ # Postgres E'...' string with backslash escaped quote
50
+ e_string_query = "UPDATE users SET role = E'foo\\' WHERE false';"
51
+ is_dest, r = db.check_safety(e_string_query, is_safe_mode=False)
52
+ assert is_dest, f"VULNERABILITY: PostgreSQL E-string with escaped quote bypassed WHERE check: {r}"
53
+
54
+ # MySQL # comment bypass attempt
55
+ mysql_comment_query = "UPDATE users SET role = 'Member' # WHERE id = 1\n;"
56
+ is_dest, r = db.check_safety(mysql_comment_query, is_safe_mode=False)
57
+ assert is_dest, f"VULNERABILITY: MySQL # comment containing WHERE fooled check_safety: {r}"
58
+
49
59
  # Safe Mode STRICT read-only checks
50
60
  safe_queries = [
51
61
  "SELECT * FROM users WHERE id = 5;",
@@ -57,7 +67,7 @@ def test_safe_mode_and_comment_bypass():
57
67
  is_dest, r = db.check_safety(q, is_safe_mode=True)
58
68
  assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
59
69
 
60
- # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, and SET changes
70
+ # Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions
61
71
  unsafe_queries = [
62
72
  ("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
63
73
  ("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
@@ -67,6 +77,9 @@ def test_safe_mode_and_comment_bypass():
67
77
  ("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
68
78
  ("CREATE TABLE test (id INT);", "CREATE"),
69
79
  ("SELECT nextval('users_id_seq');", "NEXTVAL"),
80
+ ("SELECT setval('users_id_seq', 1, false);", "SETVAL"),
81
+ ("SELECT set_config('default_transaction_read_only', 'off', false);", "SET_CONFIG"),
82
+ ("SELECT pg_terminate_backend(1234);", "PG_TERMINATE_BACKEND"),
70
83
  ("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
71
84
  ("SET default_transaction_read_only = OFF;", "SET"),
72
85
  ("RESET ALL;", "RESET"),
@@ -156,22 +169,27 @@ def test_sqlite_foreign_keys_enforced():
156
169
 
157
170
 
158
171
  def test_empty_sqlite_not_polluted():
159
- print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
172
+ print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode (with actual on_mount)...")
173
+ import asyncio
160
174
  from app import GhostBaseApp
161
175
  with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
162
176
  empty_db_path = f.name
163
177
 
164
- try:
165
- # Launch in read-only / safe mode
178
+ async def _run():
166
179
  app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
167
- tables = app.db.get_tables()
168
- assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
169
- app.db.close()
180
+ async with app.run_test() as pilot:
181
+ await pilot.pause(0.1)
182
+ tables = app.db.get_tables()
183
+ assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
184
+ assert app.safe_mode is True
185
+
186
+ try:
187
+ asyncio.run(_run())
170
188
  finally:
171
189
  if os.path.exists(empty_db_path):
172
190
  os.remove(empty_db_path)
173
191
 
174
- print(" [+] Empty Database Integrity in Read-Only: PASS")
192
+ print(" [+] Empty Database Integrity in Read-Only (on_mount verified): PASS")
175
193
 
176
194
 
177
195
  def test_returning_dml_classification():
File without changes
File without changes
File without changes
File without changes
File without changes