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.
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/PKG-INFO +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/app.py +5 -4
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/db_engine.py +67 -25
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/PKG-INFO +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/tests/test_vulnerabilities_fixed.py +26 -8
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/LICENSE +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/README.md +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/exporter.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/SOURCES.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/main.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/setup.cfg +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/tests/test_exhaustive.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.4}/tests/test_real_db.py +0 -0
|
@@ -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.
|
|
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.
|
|
398
|
-
self.
|
|
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
|
-
-
|
|
361
|
-
-
|
|
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
|
-
|
|
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 (--
|
|
386
|
-
- In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL,
|
|
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 /* ...
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
545
|
-
cur
|
|
546
|
-
|
|
547
|
-
|
|
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.
|
|
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:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostbase-cli"
|
|
7
|
-
version = "0.1.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|