ghostbase-cli 0.1.2__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.2 → ghostbase_cli-0.1.4}/PKG-INFO +1 -1
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/app.py +18 -8
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/db_engine.py +101 -21
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/exporter.py +11 -8
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/PKG-INFO +1 -1
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/tests/test_vulnerabilities_fixed.py +58 -20
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/LICENSE +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/README.md +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/SOURCES.txt +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/main.py +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/setup.cfg +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.2 → ghostbase_cli-0.1.4}/tests/test_exhaustive.py +0 -0
- {ghostbase_cli-0.1.2 → 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,9 +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
|
|
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
|
+
self.db.set_read_only(True)
|
|
396
|
+
except Exception as e:
|
|
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
|
|
395
400
|
|
|
396
401
|
tables = self.db.get_tables()
|
|
397
402
|
# Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
|
|
@@ -810,13 +815,18 @@ class GhostBaseApp(App):
|
|
|
810
815
|
self.notify("Execution Plan analyzed successfully")
|
|
811
816
|
|
|
812
817
|
def action_toggle_safe_mode(self):
|
|
813
|
-
"""Toggle Production Safe Mode (Ctrl+S)"""
|
|
814
|
-
|
|
818
|
+
"""Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
|
|
819
|
+
new_mode = not self.safe_mode
|
|
815
820
|
self._pending_destructive_query = None
|
|
816
821
|
self._pending_destructive_time = 0.0
|
|
817
822
|
|
|
818
|
-
# Enforce/revoke database session level Read-Only
|
|
819
|
-
|
|
823
|
+
# Enforce/revoke database session level Read-Only (Fail-closed)
|
|
824
|
+
try:
|
|
825
|
+
self.db.set_read_only(new_mode)
|
|
826
|
+
self.safe_mode = new_mode
|
|
827
|
+
except Exception as e:
|
|
828
|
+
self.notify(f"Cannot enable Safe Mode: {e}", severity="error", timeout=5)
|
|
829
|
+
return
|
|
820
830
|
|
|
821
831
|
badge = self.query_one("#safe-badge", Label)
|
|
822
832
|
if self.safe_mode:
|
|
@@ -905,7 +915,7 @@ class GhostBaseApp(App):
|
|
|
905
915
|
else os.getcwd()
|
|
906
916
|
)
|
|
907
917
|
out = os.path.join(db_dir, "ghostbase_export.sql")
|
|
908
|
-
Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out)
|
|
918
|
+
Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out, dialect=self.db.db_type)
|
|
909
919
|
self.notify(f"Saved: {out}", timeout=5)
|
|
910
920
|
|
|
911
921
|
# Status bar
|
|
@@ -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):
|
|
@@ -65,20 +65,24 @@ class DatabaseEngine:
|
|
|
65
65
|
"Run: pip install pymysql"
|
|
66
66
|
)
|
|
67
67
|
parsed = urllib.parse.urlparse(url)
|
|
68
|
+
# Use SSDictCursor (Server-Side streaming cursor) to protect client memory on massive queries
|
|
68
69
|
self.conn = pymysql.connect(
|
|
69
70
|
host=parsed.hostname or "localhost",
|
|
70
71
|
port=parsed.port or 3306,
|
|
71
72
|
user=parsed.username or "root",
|
|
72
73
|
password=parsed.password or "",
|
|
73
74
|
database=parsed.path.lstrip("/"),
|
|
74
|
-
cursorclass=pymysql.cursors.
|
|
75
|
+
cursorclass=pymysql.cursors.SSDictCursor,
|
|
75
76
|
autocommit=True,
|
|
76
77
|
)
|
|
77
78
|
|
|
78
|
-
def set_read_only(self, enabled: bool):
|
|
79
|
-
"""
|
|
79
|
+
def set_read_only(self, enabled: bool) -> bool:
|
|
80
|
+
"""
|
|
81
|
+
Configure database engine session-level Read-Only mode.
|
|
82
|
+
Returns True if successful. Raises RuntimeError if enabling fails (fail-closed).
|
|
83
|
+
"""
|
|
80
84
|
if not self.conn:
|
|
81
|
-
return
|
|
85
|
+
return False
|
|
82
86
|
try:
|
|
83
87
|
cur = self.conn.cursor()
|
|
84
88
|
if self.db_type == "sqlite":
|
|
@@ -90,8 +94,11 @@ class DatabaseEngine:
|
|
|
90
94
|
elif self.db_type == "mysql":
|
|
91
95
|
mode = "READ ONLY" if enabled else "READ WRITE"
|
|
92
96
|
cur.execute(f"SET SESSION TRANSACTION {mode};")
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
return True
|
|
98
|
+
except Exception as e:
|
|
99
|
+
if enabled:
|
|
100
|
+
raise RuntimeError(f"Database engine failed to activate Read-Only mode: {e}")
|
|
101
|
+
return False
|
|
95
102
|
|
|
96
103
|
def quote_table_name(self, table_name: str) -> str:
|
|
97
104
|
"""Properly quote table identifier for queries based on database engine."""
|
|
@@ -103,6 +110,9 @@ class DatabaseEngine:
|
|
|
103
110
|
return f'"{s}"."{t}"'
|
|
104
111
|
return f'"{table_name}"'
|
|
105
112
|
elif self.db_type == "mysql":
|
|
113
|
+
if "." in table_name:
|
|
114
|
+
s, t = table_name.split(".", 1)
|
|
115
|
+
return f"`{s}`.`{t}`"
|
|
106
116
|
return f"`{table_name}`"
|
|
107
117
|
else: # sqlite
|
|
108
118
|
return f"[{table_name}]"
|
|
@@ -347,10 +357,12 @@ class DatabaseEngine:
|
|
|
347
357
|
def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
|
|
348
358
|
"""
|
|
349
359
|
Extract and mask all string literals and quoted identifiers:
|
|
350
|
-
-
|
|
351
|
-
-
|
|
352
|
-
-
|
|
353
|
-
-
|
|
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: `...`
|
|
354
366
|
"""
|
|
355
367
|
literals = []
|
|
356
368
|
def _repl(m):
|
|
@@ -358,7 +370,7 @@ class DatabaseEngine:
|
|
|
358
370
|
literals.append(m.group(0))
|
|
359
371
|
return f" ___GB_LIT_{idx}___ "
|
|
360
372
|
|
|
361
|
-
pattern = r"(
|
|
373
|
+
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`)"
|
|
362
374
|
masked = re.sub(pattern, _repl, sql)
|
|
363
375
|
return masked, literals
|
|
364
376
|
|
|
@@ -366,16 +378,17 @@ class DatabaseEngine:
|
|
|
366
378
|
"""
|
|
367
379
|
Check query safety.
|
|
368
380
|
- Strings and dollar-quotes are masked FIRST so inner text ('WHERE ...') does not bypass checks.
|
|
369
|
-
- Comments (--
|
|
370
|
-
- In Safe Mode: Enforces strict Read-Only operations.
|
|
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.
|
|
371
383
|
- In Normal Mode: Intercepts unconstrained destructive queries.
|
|
372
384
|
"""
|
|
373
385
|
# 1. Mask all string literals and dollar-quoted strings FIRST
|
|
374
386
|
masked, _ = self._mask_strings_and_literals(query)
|
|
375
387
|
|
|
376
|
-
# 2. Strip block comments /* ...
|
|
388
|
+
# 2. Strip block comments /* ... */, line comments -- ..., and MySQL # ...
|
|
377
389
|
q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
378
390
|
q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
|
|
391
|
+
q_no_comments = re.sub(r"#[^\r\n]*", " ", q_no_comments)
|
|
379
392
|
|
|
380
393
|
q_clean = q_no_comments.strip()
|
|
381
394
|
if not q_clean:
|
|
@@ -397,13 +410,27 @@ class DatabaseEngine:
|
|
|
397
410
|
(r"\bGRANT\b", "GRANT statement"),
|
|
398
411
|
(r"\bREVOKE\b", "REVOKE statement"),
|
|
399
412
|
(r"\bVACUUM\b", "VACUUM statement"),
|
|
413
|
+
(r"\bSET\b", "SET configuration change"),
|
|
414
|
+
(r"\bRESET\b", "RESET configuration change"),
|
|
415
|
+
(r"\bDO\b", "DO procedural block"),
|
|
416
|
+
(r"\bCALL\b", "CALL stored procedure"),
|
|
417
|
+
(r"\bMERGE\b", "MERGE statement"),
|
|
418
|
+
(r"\bSET_CONFIG\s*\(", "SET_CONFIG parameter modification"),
|
|
419
|
+
(r"\bSETVAL\s*\(", "SETVAL sequence mutation"),
|
|
400
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"),
|
|
401
428
|
(r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
|
|
402
429
|
(r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
|
|
403
430
|
]
|
|
404
431
|
for pattern, desc in write_keywords:
|
|
405
432
|
if re.search(pattern, q_upper):
|
|
406
|
-
return True, f"Safe Mode is active: write operations ({desc}) are strictly blocked"
|
|
433
|
+
return True, f"Safe Mode is active: write or state-altering operations ({desc}) are strictly blocked"
|
|
407
434
|
|
|
408
435
|
if re.search(r"\bPRAGMA\b\s+\w+\s*=", q_upper):
|
|
409
436
|
return True, "Safe Mode is active: PRAGMA configuration writes are blocked"
|
|
@@ -427,7 +454,8 @@ class DatabaseEngine:
|
|
|
427
454
|
def explain_query(self, query: str) -> Dict[str, Any]:
|
|
428
455
|
masked, literals = self._mask_strings_and_literals(query)
|
|
429
456
|
clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
430
|
-
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(";")
|
|
431
459
|
for idx, orig in enumerate(literals):
|
|
432
460
|
clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
|
|
433
461
|
|
|
@@ -453,7 +481,7 @@ class DatabaseEngine:
|
|
|
453
481
|
sql_lines = []
|
|
454
482
|
for line in lines:
|
|
455
483
|
stripped = line.strip()
|
|
456
|
-
if stripped.startswith("--"):
|
|
484
|
+
if stripped.startswith(("--", "#")):
|
|
457
485
|
comment_lines.append(stripped)
|
|
458
486
|
else:
|
|
459
487
|
sql_lines.append(line)
|
|
@@ -504,17 +532,63 @@ class DatabaseEngine:
|
|
|
504
532
|
|
|
505
533
|
def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
|
|
506
534
|
start = time.perf_counter()
|
|
535
|
+
cur = None
|
|
507
536
|
try:
|
|
508
537
|
# Check if query is DML/DDL write operation (even if it has RETURNING)
|
|
509
538
|
masked, _ = self._mask_strings_and_literals(query)
|
|
510
539
|
clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
511
|
-
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()
|
|
512
542
|
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)
|
|
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)
|
|
514
544
|
)
|
|
515
545
|
|
|
546
|
+
# PostgreSQL streaming: Use server-side named cursor to prevent pulling entire table into client RAM
|
|
547
|
+
if self.db_type == "postgresql" and not is_write_dml:
|
|
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
|
|
551
|
+
try:
|
|
552
|
+
cur = self.conn.cursor(name=stream_name)
|
|
553
|
+
cur.itersize = max_rows + 1
|
|
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};"
|
|
589
|
+
|
|
516
590
|
cur = self.conn.cursor()
|
|
517
|
-
cur.execute(
|
|
591
|
+
cur.execute(exec_query)
|
|
518
592
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
519
593
|
|
|
520
594
|
if cur.description:
|
|
@@ -569,6 +643,12 @@ class DatabaseEngine:
|
|
|
569
643
|
"error": str(e),
|
|
570
644
|
"elapsed_ms": round(elapsed_ms, 2),
|
|
571
645
|
}
|
|
646
|
+
finally:
|
|
647
|
+
if cur:
|
|
648
|
+
try:
|
|
649
|
+
cur.close()
|
|
650
|
+
except Exception:
|
|
651
|
+
pass
|
|
572
652
|
|
|
573
653
|
# --- Helpers ---
|
|
574
654
|
|
|
@@ -40,11 +40,14 @@ class Exporter:
|
|
|
40
40
|
return deduped
|
|
41
41
|
|
|
42
42
|
@staticmethod
|
|
43
|
-
def _escape_ident(ident: str) -> str:
|
|
43
|
+
def _escape_ident(ident: str, dialect: str = "sqlite") -> str:
|
|
44
44
|
s = str(ident).strip()
|
|
45
|
+
q = "`" if str(dialect).lower() == "mysql" else '"'
|
|
46
|
+
escaped_q = q * 2
|
|
47
|
+
|
|
45
48
|
if "." in s:
|
|
46
49
|
parts = s.split(".")
|
|
47
|
-
return ".".join(f
|
|
50
|
+
return ".".join(f"{q}{p.replace(q, escaped_q)}{q}" for p in parts)
|
|
48
51
|
|
|
49
52
|
reserved = {
|
|
50
53
|
"ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE",
|
|
@@ -59,8 +62,8 @@ class Exporter:
|
|
|
59
62
|
"USER", "VALUES", "VIEW", "WHEN", "WHERE", "WITH"
|
|
60
63
|
}
|
|
61
64
|
if not s.isidentifier() or s.upper() in reserved:
|
|
62
|
-
escaped = s.replace(
|
|
63
|
-
return f
|
|
65
|
+
escaped = s.replace(q, escaped_q)
|
|
66
|
+
return f"{q}{escaped}{q}"
|
|
64
67
|
return s
|
|
65
68
|
|
|
66
69
|
@staticmethod
|
|
@@ -95,13 +98,13 @@ class Exporter:
|
|
|
95
98
|
f.write("| " + " | ".join(clean_cells) + " |\n")
|
|
96
99
|
|
|
97
100
|
@staticmethod
|
|
98
|
-
def to_sql_inserts(table_name: str, columns: List[str], rows: List[List[Any]], output_path: str):
|
|
101
|
+
def to_sql_inserts(table_name: str, columns: List[str], rows: List[List[Any]], output_path: str, dialect: str = "sqlite"):
|
|
99
102
|
table = (table_name or "exported_table").strip()
|
|
100
|
-
escaped_table = Exporter._escape_ident(table)
|
|
101
|
-
escaped_cols = [Exporter._escape_ident(c) for c in columns]
|
|
103
|
+
escaped_table = Exporter._escape_ident(table, dialect=dialect)
|
|
104
|
+
escaped_cols = [Exporter._escape_ident(c, dialect=dialect) for c in columns]
|
|
102
105
|
cols_str = ", ".join(escaped_cols)
|
|
103
106
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
104
|
-
f.write(f"-- GhostBase SQL Export: {table}\n")
|
|
107
|
+
f.write(f"-- GhostBase SQL Export: {table} ({dialect})\n")
|
|
105
108
|
f.write(f"-- Total: {len(rows)} row(s)\n\n")
|
|
106
109
|
for r in rows:
|
|
107
110
|
vals = []
|
|
@@ -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" }]
|
|
@@ -41,6 +41,21 @@ def test_safe_mode_and_comment_bypass():
|
|
|
41
41
|
is_dest, r = db.check_safety(dollar_bypass, is_safe_mode=False)
|
|
42
42
|
assert is_dest, f"VULNERABILITY: Dollar-quoted string containing WHERE fooled check_safety: {r}"
|
|
43
43
|
|
|
44
|
+
# Postgres dollar-quoted string with tag mismatch ($tag$ vs $other$)
|
|
45
|
+
mismatched_tag_query = "UPDATE users SET role = $tag$abc $other$ WHERE marker $tag$;"
|
|
46
|
+
is_dest, r = db.check_safety(mismatched_tag_query, is_safe_mode=False)
|
|
47
|
+
assert is_dest, f"VULNERABILITY: Dollar-quoted tag mismatch bypassed WHERE check: {r}"
|
|
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
|
+
|
|
44
59
|
# Safe Mode STRICT read-only checks
|
|
45
60
|
safe_queries = [
|
|
46
61
|
"SELECT * FROM users WHERE id = 5;",
|
|
@@ -52,6 +67,7 @@ def test_safe_mode_and_comment_bypass():
|
|
|
52
67
|
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
53
68
|
assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
|
|
54
69
|
|
|
70
|
+
# Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions
|
|
55
71
|
unsafe_queries = [
|
|
56
72
|
("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
|
|
57
73
|
("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
|
|
@@ -61,7 +77,15 @@ def test_safe_mode_and_comment_bypass():
|
|
|
61
77
|
("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
|
|
62
78
|
("CREATE TABLE test (id INT);", "CREATE"),
|
|
63
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"),
|
|
64
83
|
("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
|
|
84
|
+
("SET default_transaction_read_only = OFF;", "SET"),
|
|
85
|
+
("RESET ALL;", "RESET"),
|
|
86
|
+
("DO $body$ BEGIN UPDATE users SET role = 'Admin'; END $body$;", "DO"),
|
|
87
|
+
("CALL custom_proc();", "CALL"),
|
|
88
|
+
("MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE;", "MERGE"),
|
|
65
89
|
]
|
|
66
90
|
for q, op in unsafe_queries:
|
|
67
91
|
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
@@ -72,7 +96,7 @@ def test_safe_mode_and_comment_bypass():
|
|
|
72
96
|
|
|
73
97
|
|
|
74
98
|
def test_database_engine_level_readonly():
|
|
75
|
-
print("[*] 2. Testing Database Engine-Level Read-Only Enforcement...")
|
|
99
|
+
print("[*] 2. Testing Database Engine-Level Read-Only & Fail-Closed Enforcement...")
|
|
76
100
|
db = DatabaseEngine(":memory:")
|
|
77
101
|
db.conn.execute("CREATE TABLE read_only_test (id INT, val TEXT);")
|
|
78
102
|
db.conn.commit()
|
|
@@ -145,22 +169,27 @@ def test_sqlite_foreign_keys_enforced():
|
|
|
145
169
|
|
|
146
170
|
|
|
147
171
|
def test_empty_sqlite_not_polluted():
|
|
148
|
-
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
|
|
149
174
|
from app import GhostBaseApp
|
|
150
175
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
151
176
|
empty_db_path = f.name
|
|
152
177
|
|
|
153
|
-
|
|
154
|
-
# Launch in read-only / safe mode
|
|
178
|
+
async def _run():
|
|
155
179
|
app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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())
|
|
159
188
|
finally:
|
|
160
189
|
if os.path.exists(empty_db_path):
|
|
161
190
|
os.remove(empty_db_path)
|
|
162
191
|
|
|
163
|
-
print(" [+] Empty Database Integrity in Read-Only: PASS")
|
|
192
|
+
print(" [+] Empty Database Integrity in Read-Only (on_mount verified): PASS")
|
|
164
193
|
|
|
165
194
|
|
|
166
195
|
def test_returning_dml_classification():
|
|
@@ -180,11 +209,12 @@ def test_returning_dml_classification():
|
|
|
180
209
|
print(" [+] DML with RETURNING Classification: PASS")
|
|
181
210
|
|
|
182
211
|
|
|
183
|
-
def
|
|
184
|
-
print("[*] 7. Testing Exporter JSON Types & SQL
|
|
212
|
+
def test_exporter_json_types_and_dialect_sql():
|
|
213
|
+
print("[*] 7. Testing Exporter JSON Types & Dialect-Aware SQL Escaping (MySQL vs Postgres/SQLite)...")
|
|
185
214
|
tmp = tempfile.gettempdir()
|
|
186
215
|
json_path = os.path.join(tmp, "test_types.json")
|
|
187
|
-
|
|
216
|
+
sql_pg_path = os.path.join(tmp, "test_pg.sql")
|
|
217
|
+
sql_my_path = os.path.join(tmp, "test_mysql.sql")
|
|
188
218
|
|
|
189
219
|
# JSON export with Decimal, datetime, UUID, bytes, and duplicate column names
|
|
190
220
|
cols = ["id", "id", "price", "created", "uid", "raw"]
|
|
@@ -206,17 +236,24 @@ def test_exporter_dedup_and_ident_escaping():
|
|
|
206
236
|
assert data[0]["uid"] == "12345678-1234-5678-1234-567812345678"
|
|
207
237
|
assert data[0]["raw"] == "binary_ghost"
|
|
208
238
|
|
|
209
|
-
#
|
|
239
|
+
# PostgreSQL / SQLite dialect SQL export (Double quotes)
|
|
210
240
|
table_name = "analytics.order"
|
|
211
241
|
cols_special = ["first name", "group", "price"]
|
|
212
242
|
rows_special = [["Bob", "Admin", decimal.Decimal("10.50")]]
|
|
213
|
-
Exporter.to_sql_inserts(table_name, cols_special, rows_special,
|
|
214
|
-
with open(
|
|
215
|
-
|
|
243
|
+
Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_pg_path, dialect="postgresql")
|
|
244
|
+
with open(sql_pg_path, "r", encoding="utf-8") as f:
|
|
245
|
+
pg_content = f.read()
|
|
246
|
+
|
|
247
|
+
assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in pg_content
|
|
248
|
+
|
|
249
|
+
# MySQL dialect SQL export (Backticks)
|
|
250
|
+
Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_my_path, dialect="mysql")
|
|
251
|
+
with open(sql_my_path, "r", encoding="utf-8") as f:
|
|
252
|
+
my_content = f.read()
|
|
216
253
|
|
|
217
|
-
assert 'INSERT INTO
|
|
254
|
+
assert 'INSERT INTO `analytics`.`order` (`first name`, `group`, price) VALUES (\'Bob\', \'Admin\', 10.50);' in my_content
|
|
218
255
|
|
|
219
|
-
print(" [+] Exporter JSON Types & SQL Escaping: PASS")
|
|
256
|
+
print(" [+] Exporter JSON Types & Dialect-Aware SQL Escaping: PASS")
|
|
220
257
|
|
|
221
258
|
|
|
222
259
|
def test_table_quoting_and_cte_export_detection():
|
|
@@ -232,6 +269,7 @@ def test_table_quoting_and_cte_export_detection():
|
|
|
232
269
|
db_my = DatabaseEngine.__new__(DatabaseEngine)
|
|
233
270
|
db_my.db_type = "mysql"
|
|
234
271
|
assert db_my.quote_table_name("order") == "`order`"
|
|
272
|
+
assert db_my.quote_table_name("analytics.users") == "`analytics`.`users`"
|
|
235
273
|
|
|
236
274
|
# CTE target detection test
|
|
237
275
|
cte_query = """
|
|
@@ -266,7 +304,7 @@ def test_result_set_memory_limit():
|
|
|
266
304
|
|
|
267
305
|
def main():
|
|
268
306
|
print("\n" + "=" * 60)
|
|
269
|
-
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.
|
|
307
|
+
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
|
|
270
308
|
print("=" * 60)
|
|
271
309
|
|
|
272
310
|
test_safe_mode_and_comment_bypass()
|
|
@@ -275,12 +313,12 @@ def main():
|
|
|
275
313
|
test_sqlite_foreign_keys_enforced()
|
|
276
314
|
test_empty_sqlite_not_polluted()
|
|
277
315
|
test_returning_dml_classification()
|
|
278
|
-
|
|
316
|
+
test_exporter_json_types_and_dialect_sql()
|
|
279
317
|
test_table_quoting_and_cte_export_detection()
|
|
280
318
|
test_result_set_memory_limit()
|
|
281
319
|
|
|
282
320
|
print("\n" + "=" * 60)
|
|
283
|
-
print("ALL
|
|
321
|
+
print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
|
|
284
322
|
print("=" * 60 + "\n")
|
|
285
323
|
|
|
286
324
|
|
|
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
|