ghostbase-cli 0.1.1__tar.gz → 0.1.3__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.1 → ghostbase_cli-0.1.3}/PKG-INFO +1 -1
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/app.py +68 -12
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/db_engine.py +138 -25
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/exporter.py +37 -9
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/PKG-INFO +1 -1
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/pyproject.toml +1 -1
- ghostbase_cli-0.1.3/tests/test_vulnerabilities_fixed.py +308 -0
- ghostbase_cli-0.1.1/tests/test_vulnerabilities_fixed.py +0 -216
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/LICENSE +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/README.md +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/SOURCES.txt +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/main.py +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/setup.cfg +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/tests/test_exhaustive.py +0 -0
- {ghostbase_cli-0.1.1 → ghostbase_cli-0.1.3}/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.3", id="version-label")
|
|
346
346
|
|
|
347
347
|
with Horizontal(id="main-container"):
|
|
348
348
|
with Vertical(id="sidebar"):
|
|
@@ -389,14 +389,22 @@ 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)
|
|
393
|
+
if self.safe_mode:
|
|
394
|
+
try:
|
|
395
|
+
self.db.set_read_only(True)
|
|
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")
|
|
399
|
+
|
|
392
400
|
tables = self.db.get_tables()
|
|
393
|
-
# Only populate demo tables when explicitly requested, in-memory, or default sample.db
|
|
401
|
+
# Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
|
|
394
402
|
is_default_sample = (
|
|
395
403
|
self.populate_sample
|
|
396
404
|
or self.db_path == ":memory:"
|
|
397
405
|
or os.path.basename(os.path.abspath(self.db_path)) == "sample.db"
|
|
398
406
|
)
|
|
399
|
-
if not tables and self.db.db_type == "sqlite" and is_default_sample:
|
|
407
|
+
if not tables and self.db.db_type == "sqlite" and is_default_sample and not self.safe_mode:
|
|
400
408
|
self._create_sample_data()
|
|
401
409
|
tables = self.db.get_tables()
|
|
402
410
|
|
|
@@ -408,8 +416,9 @@ class GhostBaseApp(App):
|
|
|
408
416
|
if self.all_tables:
|
|
409
417
|
first = self.all_tables[0]
|
|
410
418
|
self.selected_table = first
|
|
419
|
+
quoted = self.db.quote_table_name(first)
|
|
411
420
|
self.query_one("#query-box", TextArea).text = (
|
|
412
|
-
f"SELECT * FROM {
|
|
421
|
+
f"SELECT * FROM {quoted} LIMIT 50;"
|
|
413
422
|
)
|
|
414
423
|
self.action_run_query()
|
|
415
424
|
self._show_schema(first)
|
|
@@ -586,8 +595,9 @@ class GhostBaseApp(App):
|
|
|
586
595
|
self._pending_destructive_query = None
|
|
587
596
|
self._pending_destructive_time = 0.0
|
|
588
597
|
|
|
598
|
+
quoted = self.db.quote_table_name(t)
|
|
589
599
|
self.query_one("#query-box", TextArea).text = (
|
|
590
|
-
f"SELECT * FROM {
|
|
600
|
+
f"SELECT * FROM {quoted} LIMIT 50;"
|
|
591
601
|
)
|
|
592
602
|
self.action_run_query()
|
|
593
603
|
self._show_schema(t, is_view=is_view)
|
|
@@ -673,9 +683,10 @@ class GhostBaseApp(App):
|
|
|
673
683
|
self.last_columns = res["columns"]
|
|
674
684
|
self.last_rows = res["rows"]
|
|
675
685
|
|
|
676
|
-
# Detect target table for export
|
|
677
|
-
|
|
678
|
-
|
|
686
|
+
# Detect target table for export from final SELECT statement (ignoring CTE definitions)
|
|
687
|
+
main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", query, flags=re.IGNORECASE)
|
|
688
|
+
m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
|
|
689
|
+
self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
|
|
679
690
|
|
|
680
691
|
dt.add_columns(*self.last_columns)
|
|
681
692
|
for row in self.last_rows:
|
|
@@ -702,9 +713,46 @@ class GhostBaseApp(App):
|
|
|
702
713
|
else:
|
|
703
714
|
res_cnt.update(f"({rc} rows)")
|
|
704
715
|
|
|
716
|
+
self._update_status_bar(row_count=rc, ms=ms)
|
|
717
|
+
elif res.get("is_returning"):
|
|
718
|
+
# DML with RETURNING: Render returned rows in DataTable, but treat as write operation
|
|
719
|
+
self.last_columns = res["columns"]
|
|
720
|
+
self.last_rows = res["rows"]
|
|
721
|
+
|
|
722
|
+
# Extract target table from INSERT/UPDATE
|
|
723
|
+
m = re.search(r"\b(?:INTO|UPDATE)\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", query, flags=re.IGNORECASE)
|
|
724
|
+
self.last_query_table = m.group(1).strip('`"[]') if m else (self.selected_table or "query_result")
|
|
725
|
+
|
|
726
|
+
dt.add_columns(*self.last_columns)
|
|
727
|
+
for row in self.last_rows:
|
|
728
|
+
styled = []
|
|
729
|
+
for val in row:
|
|
730
|
+
if val is None:
|
|
731
|
+
styled.append(Text("NULL", style="dim #6e7681"))
|
|
732
|
+
elif isinstance(val, (int, float)):
|
|
733
|
+
styled.append(Text(str(val), style="#79c0ff"))
|
|
734
|
+
else:
|
|
735
|
+
s = str(val).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
|
|
736
|
+
if len(s) > 200:
|
|
737
|
+
s = s[:197] + "..."
|
|
738
|
+
styled.append(Text(s, style="#c9d1d9"))
|
|
739
|
+
dt.add_row(*styled)
|
|
740
|
+
|
|
741
|
+
aff = res.get("affected_rows", len(self.last_rows))
|
|
742
|
+
rc = res.get("row_count", len(self.last_rows))
|
|
743
|
+
ms = res["elapsed_ms"]
|
|
744
|
+
status.update(f"[bold green]OK[/] ({ms}ms)")
|
|
745
|
+
res_cnt.update(f"({rc} rows returned, {aff} affected)")
|
|
746
|
+
self.notify(f"Done — {aff} row(s) affected (RETURNING {rc} rows)")
|
|
747
|
+
|
|
748
|
+
# Refresh schema & row counts
|
|
749
|
+
self.all_tables = self.db.get_tables()
|
|
750
|
+
self.all_views = self.db.get_views()
|
|
751
|
+
self._refresh_row_counts()
|
|
752
|
+
self._refresh_sidebar(self.all_tables, self.all_views)
|
|
705
753
|
self._update_status_bar(row_count=rc, ms=ms)
|
|
706
754
|
else:
|
|
707
|
-
#
|
|
755
|
+
# Pure DML / DDL statement -> clear export cache so previous SELECT results are not mistakenly exported
|
|
708
756
|
self.last_columns = []
|
|
709
757
|
self.last_rows = []
|
|
710
758
|
self.last_query_table = None
|
|
@@ -766,11 +814,19 @@ class GhostBaseApp(App):
|
|
|
766
814
|
self.notify("Execution Plan analyzed successfully")
|
|
767
815
|
|
|
768
816
|
def action_toggle_safe_mode(self):
|
|
769
|
-
"""Toggle Production Safe Mode (Ctrl+S)"""
|
|
770
|
-
|
|
817
|
+
"""Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
|
|
818
|
+
new_mode = not self.safe_mode
|
|
771
819
|
self._pending_destructive_query = None
|
|
772
820
|
self._pending_destructive_time = 0.0
|
|
773
821
|
|
|
822
|
+
# Enforce/revoke database session level Read-Only (Fail-closed)
|
|
823
|
+
try:
|
|
824
|
+
self.db.set_read_only(new_mode)
|
|
825
|
+
self.safe_mode = new_mode
|
|
826
|
+
except Exception as e:
|
|
827
|
+
self.notify(f"Cannot enable Safe Mode: {e}", severity="error", timeout=5)
|
|
828
|
+
return
|
|
829
|
+
|
|
774
830
|
badge = self.query_one("#safe-badge", Label)
|
|
775
831
|
if self.safe_mode:
|
|
776
832
|
badge.update("[SAFE: ON]")
|
|
@@ -858,7 +914,7 @@ class GhostBaseApp(App):
|
|
|
858
914
|
else os.getcwd()
|
|
859
915
|
)
|
|
860
916
|
out = os.path.join(db_dir, "ghostbase_export.sql")
|
|
861
|
-
Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out)
|
|
917
|
+
Exporter.to_sql_inserts(table_name, self.last_columns, self.last_rows, out, dialect=self.db.db_type)
|
|
862
918
|
self.notify(f"Saved: {out}", timeout=5)
|
|
863
919
|
|
|
864
920
|
# Status bar
|
|
@@ -65,16 +65,58 @@ 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
|
|
|
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
|
+
"""
|
|
84
|
+
if not self.conn:
|
|
85
|
+
return False
|
|
86
|
+
try:
|
|
87
|
+
cur = self.conn.cursor()
|
|
88
|
+
if self.db_type == "sqlite":
|
|
89
|
+
val = "ON" if enabled else "OFF"
|
|
90
|
+
cur.execute(f"PRAGMA query_only = {val};")
|
|
91
|
+
elif self.db_type == "postgresql":
|
|
92
|
+
mode = "ON" if enabled else "OFF"
|
|
93
|
+
cur.execute(f"SET default_transaction_read_only = {mode};")
|
|
94
|
+
elif self.db_type == "mysql":
|
|
95
|
+
mode = "READ ONLY" if enabled else "READ WRITE"
|
|
96
|
+
cur.execute(f"SET SESSION TRANSACTION {mode};")
|
|
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
|
|
102
|
+
|
|
103
|
+
def quote_table_name(self, table_name: str) -> str:
|
|
104
|
+
"""Properly quote table identifier for queries based on database engine."""
|
|
105
|
+
if not table_name:
|
|
106
|
+
return ""
|
|
107
|
+
if self.db_type == "postgresql":
|
|
108
|
+
if "." in table_name:
|
|
109
|
+
s, t = table_name.split(".", 1)
|
|
110
|
+
return f'"{s}"."{t}"'
|
|
111
|
+
return f'"{table_name}"'
|
|
112
|
+
elif self.db_type == "mysql":
|
|
113
|
+
if "." in table_name:
|
|
114
|
+
s, t = table_name.split(".", 1)
|
|
115
|
+
return f"`{s}`.`{t}`"
|
|
116
|
+
return f"`{table_name}`"
|
|
117
|
+
else: # sqlite
|
|
118
|
+
return f"[{table_name}]"
|
|
119
|
+
|
|
78
120
|
# --- Schema discovery ---
|
|
79
121
|
|
|
80
122
|
def get_tables(self) -> List[str]:
|
|
@@ -266,16 +308,28 @@ class DatabaseEngine:
|
|
|
266
308
|
if self.db_type == "sqlite":
|
|
267
309
|
cur.execute(f"SELECT COUNT(*) FROM [{table_name}];")
|
|
268
310
|
elif self.db_type == "postgresql":
|
|
269
|
-
|
|
311
|
+
schema = "public"
|
|
312
|
+
tbl = table_name
|
|
313
|
+
if "." in table_name:
|
|
314
|
+
schema, tbl = table_name.split(".", 1)
|
|
315
|
+
|
|
270
316
|
if fast_estimate:
|
|
271
317
|
try:
|
|
272
|
-
cur.execute(
|
|
318
|
+
cur.execute(
|
|
319
|
+
"""
|
|
320
|
+
SELECT c.reltuples::bigint
|
|
321
|
+
FROM pg_class c
|
|
322
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
323
|
+
WHERE n.nspname = %s AND c.relname = %s;
|
|
324
|
+
""",
|
|
325
|
+
(schema, tbl),
|
|
326
|
+
)
|
|
273
327
|
r = cur.fetchone()
|
|
274
328
|
if r and r[0] is not None and r[0] >= 0:
|
|
275
329
|
return int(r[0])
|
|
276
330
|
except Exception:
|
|
277
331
|
pass
|
|
278
|
-
cur.execute(f'SELECT COUNT(*) FROM "{
|
|
332
|
+
cur.execute(f'SELECT COUNT(*) FROM "{schema}"."{tbl}";')
|
|
279
333
|
else:
|
|
280
334
|
if fast_estimate:
|
|
281
335
|
try:
|
|
@@ -299,20 +353,47 @@ class DatabaseEngine:
|
|
|
299
353
|
|
|
300
354
|
# --- Query safety and formatting ---
|
|
301
355
|
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
|
|
358
|
+
"""
|
|
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)
|
|
362
|
+
- Standard double-quoted strings / identifiers: "..."
|
|
363
|
+
- MySQL backtick identifiers: `...`
|
|
364
|
+
"""
|
|
365
|
+
literals = []
|
|
366
|
+
def _repl(m):
|
|
367
|
+
idx = len(literals)
|
|
368
|
+
literals.append(m.group(0))
|
|
369
|
+
return f" ___GB_LIT_{idx}___ "
|
|
370
|
+
|
|
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\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
|
|
378
|
+
masked = re.sub(pattern, _repl, sql)
|
|
379
|
+
return masked, literals
|
|
380
|
+
|
|
302
381
|
def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
|
|
303
382
|
"""
|
|
304
383
|
Check query safety.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
384
|
+
- 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.
|
|
387
|
+
- In Normal Mode: Intercepts unconstrained destructive queries.
|
|
308
388
|
"""
|
|
309
|
-
# 1.
|
|
310
|
-
|
|
389
|
+
# 1. Mask all string literals and dollar-quoted strings FIRST
|
|
390
|
+
masked, _ = self._mask_strings_and_literals(query)
|
|
391
|
+
|
|
392
|
+
# 2. Strip block comments /* ... */ and line comments -- ...
|
|
393
|
+
q_no_comments = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
311
394
|
q_no_comments = re.sub(r"--[^\r\n]*", " ", q_no_comments)
|
|
312
395
|
|
|
313
|
-
|
|
314
|
-
q_clean = re.sub(r"('(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)", " ", q_no_comments)
|
|
315
|
-
q_clean = q_clean.strip()
|
|
396
|
+
q_clean = q_no_comments.strip()
|
|
316
397
|
if not q_clean:
|
|
317
398
|
return False, ""
|
|
318
399
|
|
|
@@ -332,10 +413,18 @@ class DatabaseEngine:
|
|
|
332
413
|
(r"\bGRANT\b", "GRANT statement"),
|
|
333
414
|
(r"\bREVOKE\b", "REVOKE statement"),
|
|
334
415
|
(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
|
+
(r"\bSET\b", "SET configuration change"),
|
|
420
|
+
(r"\bRESET\b", "RESET configuration change"),
|
|
421
|
+
(r"\bDO\b", "DO procedural block"),
|
|
422
|
+
(r"\bCALL\b", "CALL stored procedure"),
|
|
423
|
+
(r"\bMERGE\b", "MERGE statement"),
|
|
335
424
|
]
|
|
336
425
|
for pattern, desc in write_keywords:
|
|
337
426
|
if re.search(pattern, q_upper):
|
|
338
|
-
return True, f"Safe Mode is active: write operations ({desc}) are strictly blocked"
|
|
427
|
+
return True, f"Safe Mode is active: write or state-altering operations ({desc}) are strictly blocked"
|
|
339
428
|
|
|
340
429
|
if re.search(r"\bPRAGMA\b\s+\w+\s*=", q_upper):
|
|
341
430
|
return True, "Safe Mode is active: PRAGMA configuration writes are blocked"
|
|
@@ -357,8 +446,12 @@ class DatabaseEngine:
|
|
|
357
446
|
return False, ""
|
|
358
447
|
|
|
359
448
|
def explain_query(self, query: str) -> Dict[str, Any]:
|
|
360
|
-
|
|
449
|
+
masked, literals = self._mask_strings_and_literals(query)
|
|
450
|
+
clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
361
451
|
clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().rstrip(";")
|
|
452
|
+
for idx, orig in enumerate(literals):
|
|
453
|
+
clean_q = clean_q.replace(f"___GB_LIT_{idx}___", orig)
|
|
454
|
+
|
|
362
455
|
if not clean_q:
|
|
363
456
|
return {"success": False, "error": "Query is empty"}
|
|
364
457
|
|
|
@@ -374,15 +467,7 @@ class DatabaseEngine:
|
|
|
374
467
|
if not query or not query.strip():
|
|
375
468
|
return query
|
|
376
469
|
|
|
377
|
-
|
|
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)
|
|
470
|
+
masked, literals = DatabaseEngine._mask_strings_and_literals(query)
|
|
386
471
|
|
|
387
472
|
lines = masked.splitlines()
|
|
388
473
|
comment_lines = []
|
|
@@ -440,8 +525,27 @@ class DatabaseEngine:
|
|
|
440
525
|
|
|
441
526
|
def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
|
|
442
527
|
start = time.perf_counter()
|
|
528
|
+
cur = None
|
|
443
529
|
try:
|
|
444
|
-
|
|
530
|
+
# Check if query is DML/DDL write operation (even if it has RETURNING)
|
|
531
|
+
masked, _ = self._mask_strings_and_literals(query)
|
|
532
|
+
clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
533
|
+
clean_q = re.sub(r"--[^\r\n]*", " ", clean_q).strip().upper()
|
|
534
|
+
is_write_dml = bool(
|
|
535
|
+
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
|
+
)
|
|
537
|
+
|
|
538
|
+
# Use server-side streaming cursor for PostgreSQL read queries to prevent client RAM overflow
|
|
539
|
+
if self.db_type == "postgresql" and not is_write_dml:
|
|
540
|
+
stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
|
|
541
|
+
try:
|
|
542
|
+
cur = self.conn.cursor(name=stream_name)
|
|
543
|
+
cur.itersize = max_rows + 1
|
|
544
|
+
except Exception:
|
|
545
|
+
cur = self.conn.cursor()
|
|
546
|
+
else:
|
|
547
|
+
cur = self.conn.cursor()
|
|
548
|
+
|
|
445
549
|
cur.execute(query)
|
|
446
550
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
447
551
|
|
|
@@ -469,9 +573,11 @@ class DatabaseEngine:
|
|
|
469
573
|
"columns": columns,
|
|
470
574
|
"rows": rows,
|
|
471
575
|
"row_count": len(rows),
|
|
576
|
+
"affected_rows": max(cur.rowcount, len(rows)) if is_write_dml else 0,
|
|
472
577
|
"truncated": truncated,
|
|
473
578
|
"elapsed_ms": round(elapsed_ms, 2),
|
|
474
|
-
"is_select":
|
|
579
|
+
"is_select": not is_write_dml,
|
|
580
|
+
"is_returning": is_write_dml,
|
|
475
581
|
}
|
|
476
582
|
else:
|
|
477
583
|
if self.db_type in ("sqlite", "postgresql"):
|
|
@@ -481,6 +587,7 @@ class DatabaseEngine:
|
|
|
481
587
|
"affected_rows": max(cur.rowcount, 0),
|
|
482
588
|
"elapsed_ms": round(elapsed_ms, 2),
|
|
483
589
|
"is_select": False,
|
|
590
|
+
"is_returning": False,
|
|
484
591
|
}
|
|
485
592
|
except Exception as e:
|
|
486
593
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
@@ -494,6 +601,12 @@ class DatabaseEngine:
|
|
|
494
601
|
"error": str(e),
|
|
495
602
|
"elapsed_ms": round(elapsed_ms, 2),
|
|
496
603
|
}
|
|
604
|
+
finally:
|
|
605
|
+
if cur:
|
|
606
|
+
try:
|
|
607
|
+
cur.close()
|
|
608
|
+
except Exception:
|
|
609
|
+
pass
|
|
497
610
|
|
|
498
611
|
# --- Helpers ---
|
|
499
612
|
|
|
@@ -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 = {}
|
|
@@ -19,8 +40,15 @@ class Exporter:
|
|
|
19
40
|
return deduped
|
|
20
41
|
|
|
21
42
|
@staticmethod
|
|
22
|
-
def _escape_ident(ident: str) -> str:
|
|
43
|
+
def _escape_ident(ident: str, dialect: str = "sqlite") -> str:
|
|
23
44
|
s = str(ident).strip()
|
|
45
|
+
q = "`" if str(dialect).lower() == "mysql" else '"'
|
|
46
|
+
escaped_q = q * 2
|
|
47
|
+
|
|
48
|
+
if "." in s:
|
|
49
|
+
parts = s.split(".")
|
|
50
|
+
return ".".join(f"{q}{p.replace(q, escaped_q)}{q}" for p in parts)
|
|
51
|
+
|
|
24
52
|
reserved = {
|
|
25
53
|
"ALL", "ALTER", "AND", "ANY", "AS", "ASC", "BETWEEN", "BY", "CASE",
|
|
26
54
|
"CHECK", "COLUMN", "CONSTRAINT", "CREATE", "CROSS", "CURRENT_DATE",
|
|
@@ -34,8 +62,8 @@ class Exporter:
|
|
|
34
62
|
"USER", "VALUES", "VIEW", "WHEN", "WHERE", "WITH"
|
|
35
63
|
}
|
|
36
64
|
if not s.isidentifier() or s.upper() in reserved:
|
|
37
|
-
escaped = s.replace(
|
|
38
|
-
return f
|
|
65
|
+
escaped = s.replace(q, escaped_q)
|
|
66
|
+
return f"{q}{escaped}{q}"
|
|
39
67
|
return s
|
|
40
68
|
|
|
41
69
|
@staticmethod
|
|
@@ -52,7 +80,7 @@ class Exporter:
|
|
|
52
80
|
for r in rows:
|
|
53
81
|
data.append(dict(zip(clean_cols, r)))
|
|
54
82
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
55
|
-
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
83
|
+
json.dump(data, f, indent=2, ensure_ascii=False, default=Exporter._json_serial)
|
|
56
84
|
|
|
57
85
|
@staticmethod
|
|
58
86
|
def to_markdown(columns: List[str], rows: List[List[Any]], output_path: str):
|
|
@@ -70,13 +98,13 @@ class Exporter:
|
|
|
70
98
|
f.write("| " + " | ".join(clean_cells) + " |\n")
|
|
71
99
|
|
|
72
100
|
@staticmethod
|
|
73
|
-
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"):
|
|
74
102
|
table = (table_name or "exported_table").strip()
|
|
75
|
-
escaped_table = Exporter._escape_ident(table)
|
|
76
|
-
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]
|
|
77
105
|
cols_str = ", ".join(escaped_cols)
|
|
78
106
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
79
|
-
f.write(f"-- GhostBase SQL Export: {table}\n")
|
|
107
|
+
f.write(f"-- GhostBase SQL Export: {table} ({dialect})\n")
|
|
80
108
|
f.write(f"-- Total: {len(rows)} row(s)\n\n")
|
|
81
109
|
for r in rows:
|
|
82
110
|
vals = []
|
|
@@ -85,7 +113,7 @@ class Exporter:
|
|
|
85
113
|
vals.append("NULL")
|
|
86
114
|
elif isinstance(v, bool):
|
|
87
115
|
vals.append("TRUE" if v else "FALSE")
|
|
88
|
-
elif isinstance(v, (int, float)):
|
|
116
|
+
elif isinstance(v, (int, float, decimal.Decimal)):
|
|
89
117
|
vals.append(str(v))
|
|
90
118
|
else:
|
|
91
119
|
escaped = str(v).replace("'", "''")
|
|
@@ -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.3"
|
|
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,308 @@
|
|
|
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
|
+
# 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
|
+
# Safe Mode STRICT read-only checks
|
|
50
|
+
safe_queries = [
|
|
51
|
+
"SELECT * FROM users WHERE id = 5;",
|
|
52
|
+
"SELECT 'DROP TABLE users' AS msg;",
|
|
53
|
+
"WITH cte AS (SELECT 1 AS n) SELECT * FROM cte;",
|
|
54
|
+
"EXPLAIN SELECT * FROM users;",
|
|
55
|
+
]
|
|
56
|
+
for q in safe_queries:
|
|
57
|
+
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
58
|
+
assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
|
|
59
|
+
|
|
60
|
+
# Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, and SET changes
|
|
61
|
+
unsafe_queries = [
|
|
62
|
+
("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
|
|
63
|
+
("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
|
|
64
|
+
("DELETE FROM users WHERE id = 5;", "DELETE"),
|
|
65
|
+
("DROP TABLE users;", "DROP"),
|
|
66
|
+
("TRUNCATE TABLE users;", "TRUNCATE"),
|
|
67
|
+
("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
|
|
68
|
+
("CREATE TABLE test (id INT);", "CREATE"),
|
|
69
|
+
("SELECT nextval('users_id_seq');", "NEXTVAL"),
|
|
70
|
+
("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
|
|
71
|
+
("SET default_transaction_read_only = OFF;", "SET"),
|
|
72
|
+
("RESET ALL;", "RESET"),
|
|
73
|
+
("DO $body$ BEGIN UPDATE users SET role = 'Admin'; END $body$;", "DO"),
|
|
74
|
+
("CALL custom_proc();", "CALL"),
|
|
75
|
+
("MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE;", "MERGE"),
|
|
76
|
+
]
|
|
77
|
+
for q, op in unsafe_queries:
|
|
78
|
+
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
79
|
+
assert is_dest, f"VULNERABILITY: Safe Mode failed to block {op}: {q}"
|
|
80
|
+
assert "Safe Mode is active" in r
|
|
81
|
+
|
|
82
|
+
print(" [+] Safe Mode & Comment Bypass: PASS")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_database_engine_level_readonly():
|
|
86
|
+
print("[*] 2. Testing Database Engine-Level Read-Only & Fail-Closed Enforcement...")
|
|
87
|
+
db = DatabaseEngine(":memory:")
|
|
88
|
+
db.conn.execute("CREATE TABLE read_only_test (id INT, val TEXT);")
|
|
89
|
+
db.conn.commit()
|
|
90
|
+
|
|
91
|
+
# Enable engine-level read-only
|
|
92
|
+
db.set_read_only(True)
|
|
93
|
+
|
|
94
|
+
# SQLite PRAGMA query_only=ON must reject writes at the C/engine level
|
|
95
|
+
failed = False
|
|
96
|
+
try:
|
|
97
|
+
db.conn.execute("INSERT INTO read_only_test VALUES (1, 'fail');")
|
|
98
|
+
db.conn.commit()
|
|
99
|
+
except sqlite3.OperationalError as e:
|
|
100
|
+
if "readonly" in str(e).lower() or "query_only" in str(e).lower():
|
|
101
|
+
failed = True
|
|
102
|
+
|
|
103
|
+
assert failed, "VULNERABILITY: SQLite allowed write with PRAGMA query_only = ON!"
|
|
104
|
+
|
|
105
|
+
# Disable read-only
|
|
106
|
+
db.set_read_only(False)
|
|
107
|
+
db.conn.execute("INSERT INTO read_only_test VALUES (1, 'success');")
|
|
108
|
+
db.conn.commit()
|
|
109
|
+
|
|
110
|
+
print(" [+] Database Engine-Level Read-Only: PASS")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_sql_beautifier_preserves_literals():
|
|
114
|
+
print("[*] 3. Testing SQL Beautifier Literal Integrity (Single, Double & Dollar Quotes)...")
|
|
115
|
+
raw_query = "SELECT 'order by and where' AS message, $$order by AND where$$ AS dollar_msg FROM users WHERE id = 1;"
|
|
116
|
+
formatted = DatabaseEngine.format_sql(raw_query)
|
|
117
|
+
|
|
118
|
+
assert "'order by and where'" in formatted, f"VULNERABILITY: Beautifier corrupted single-quoted literal: {formatted}"
|
|
119
|
+
assert "$$order by AND where$$" in formatted, f"VULNERABILITY: Beautifier corrupted dollar-quoted literal: {formatted}"
|
|
120
|
+
assert "WHERE" in formatted
|
|
121
|
+
assert "SELECT" in formatted
|
|
122
|
+
|
|
123
|
+
print(" [+] SQL Beautifier Preserves Literals: PASS")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_sqlite_foreign_keys_enforced():
|
|
127
|
+
print("[*] 4. Testing SQLite Foreign Keys Enforcement...")
|
|
128
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
129
|
+
db_path = f.name
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
db = DatabaseEngine(db_path)
|
|
133
|
+
cur = db.conn.cursor()
|
|
134
|
+
cur.execute("PRAGMA foreign_keys;")
|
|
135
|
+
fk_status = cur.fetchone()[0]
|
|
136
|
+
assert fk_status == 1, f"VULNERABILITY: PRAGMA foreign_keys is OFF ({fk_status})"
|
|
137
|
+
|
|
138
|
+
db.conn.execute("CREATE TABLE dept (id INTEGER PRIMARY KEY, name TEXT);")
|
|
139
|
+
db.conn.execute("CREATE TABLE emp (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES dept(id));")
|
|
140
|
+
db.conn.commit()
|
|
141
|
+
|
|
142
|
+
failed = False
|
|
143
|
+
try:
|
|
144
|
+
db.conn.execute("INSERT INTO emp (id, dept_id) VALUES (1, 999);")
|
|
145
|
+
db.conn.commit()
|
|
146
|
+
except sqlite3.IntegrityError:
|
|
147
|
+
failed = True
|
|
148
|
+
|
|
149
|
+
assert failed, "VULNERABILITY: SQLite allowed foreign key violation without error!"
|
|
150
|
+
finally:
|
|
151
|
+
db.close()
|
|
152
|
+
if os.path.exists(db_path):
|
|
153
|
+
os.remove(db_path)
|
|
154
|
+
|
|
155
|
+
print(" [+] SQLite Foreign Key Enforcement: PASS")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_empty_sqlite_not_polluted():
|
|
159
|
+
print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
|
|
160
|
+
from app import GhostBaseApp
|
|
161
|
+
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
162
|
+
empty_db_path = f.name
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
# Launch in read-only / safe mode
|
|
166
|
+
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()
|
|
170
|
+
finally:
|
|
171
|
+
if os.path.exists(empty_db_path):
|
|
172
|
+
os.remove(empty_db_path)
|
|
173
|
+
|
|
174
|
+
print(" [+] Empty Database Integrity in Read-Only: PASS")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_returning_dml_classification():
|
|
178
|
+
print("[*] 6. Testing DML with RETURNING Classification...")
|
|
179
|
+
db = DatabaseEngine(":memory:")
|
|
180
|
+
db.conn.execute("CREATE TABLE ret_test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);")
|
|
181
|
+
db.conn.commit()
|
|
182
|
+
|
|
183
|
+
res = db.execute_query("INSERT INTO ret_test (name) VALUES ('Item1') RETURNING id, name;")
|
|
184
|
+
assert res["success"]
|
|
185
|
+
assert res["is_returning"] is True, "Expected is_returning to be True"
|
|
186
|
+
assert res["is_select"] is False, "Expected is_select to be False for INSERT RETURNING"
|
|
187
|
+
assert res["affected_rows"] == 1
|
|
188
|
+
assert len(res["rows"]) == 1
|
|
189
|
+
assert res["rows"][0][1] == "Item1"
|
|
190
|
+
|
|
191
|
+
print(" [+] DML with RETURNING Classification: PASS")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_exporter_json_types_and_dialect_sql():
|
|
195
|
+
print("[*] 7. Testing Exporter JSON Types & Dialect-Aware SQL Escaping (MySQL vs Postgres/SQLite)...")
|
|
196
|
+
tmp = tempfile.gettempdir()
|
|
197
|
+
json_path = os.path.join(tmp, "test_types.json")
|
|
198
|
+
sql_pg_path = os.path.join(tmp, "test_pg.sql")
|
|
199
|
+
sql_my_path = os.path.join(tmp, "test_mysql.sql")
|
|
200
|
+
|
|
201
|
+
# JSON export with Decimal, datetime, UUID, bytes, and duplicate column names
|
|
202
|
+
cols = ["id", "id", "price", "created", "uid", "raw"]
|
|
203
|
+
rows = [[
|
|
204
|
+
1, 1,
|
|
205
|
+
decimal.Decimal("49.95"),
|
|
206
|
+
datetime.datetime(2026, 11, 2, 8, 0, 0),
|
|
207
|
+
uuid.UUID("12345678-1234-5678-1234-567812345678"),
|
|
208
|
+
b"binary_ghost"
|
|
209
|
+
]]
|
|
210
|
+
Exporter.to_json(cols, rows, json_path)
|
|
211
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
212
|
+
data = json.load(f)
|
|
213
|
+
assert len(data) == 1
|
|
214
|
+
assert data[0]["id"] == 1
|
|
215
|
+
assert data[0]["id_2"] == 1
|
|
216
|
+
assert data[0]["price"] == 49.95
|
|
217
|
+
assert data[0]["created"] == "2026-11-02T08:00:00"
|
|
218
|
+
assert data[0]["uid"] == "12345678-1234-5678-1234-567812345678"
|
|
219
|
+
assert data[0]["raw"] == "binary_ghost"
|
|
220
|
+
|
|
221
|
+
# PostgreSQL / SQLite dialect SQL export (Double quotes)
|
|
222
|
+
table_name = "analytics.order"
|
|
223
|
+
cols_special = ["first name", "group", "price"]
|
|
224
|
+
rows_special = [["Bob", "Admin", decimal.Decimal("10.50")]]
|
|
225
|
+
Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_pg_path, dialect="postgresql")
|
|
226
|
+
with open(sql_pg_path, "r", encoding="utf-8") as f:
|
|
227
|
+
pg_content = f.read()
|
|
228
|
+
|
|
229
|
+
assert 'INSERT INTO "analytics"."order" ("first name", "group", price) VALUES (\'Bob\', \'Admin\', 10.50);' in pg_content
|
|
230
|
+
|
|
231
|
+
# MySQL dialect SQL export (Backticks)
|
|
232
|
+
Exporter.to_sql_inserts(table_name, cols_special, rows_special, sql_my_path, dialect="mysql")
|
|
233
|
+
with open(sql_my_path, "r", encoding="utf-8") as f:
|
|
234
|
+
my_content = f.read()
|
|
235
|
+
|
|
236
|
+
assert 'INSERT INTO `analytics`.`order` (`first name`, `group`, price) VALUES (\'Bob\', \'Admin\', 10.50);' in my_content
|
|
237
|
+
|
|
238
|
+
print(" [+] Exporter JSON Types & Dialect-Aware SQL Escaping: PASS")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_table_quoting_and_cte_export_detection():
|
|
242
|
+
print("[*] 8. Testing Table Quoting & CTE Export Target Detection...")
|
|
243
|
+
db_sqlite = DatabaseEngine(":memory:")
|
|
244
|
+
assert db_sqlite.quote_table_name("order") == "[order]"
|
|
245
|
+
|
|
246
|
+
db_pg = DatabaseEngine.__new__(DatabaseEngine)
|
|
247
|
+
db_pg.db_type = "postgresql"
|
|
248
|
+
assert db_pg.quote_table_name("order") == '"order"'
|
|
249
|
+
assert db_pg.quote_table_name("analytics.users") == '"analytics"."users"'
|
|
250
|
+
|
|
251
|
+
db_my = DatabaseEngine.__new__(DatabaseEngine)
|
|
252
|
+
db_my.db_type = "mysql"
|
|
253
|
+
assert db_my.quote_table_name("order") == "`order`"
|
|
254
|
+
assert db_my.quote_table_name("analytics.users") == "`analytics`.`users`"
|
|
255
|
+
|
|
256
|
+
# CTE target detection test
|
|
257
|
+
cte_query = """
|
|
258
|
+
WITH recent AS (
|
|
259
|
+
SELECT * FROM archived_users WHERE active = 0
|
|
260
|
+
)
|
|
261
|
+
SELECT * FROM recent;
|
|
262
|
+
"""
|
|
263
|
+
main_q = re.sub(r"^WITH\s+[\s\S]*?\)\s*", "", cte_query.strip(), flags=re.IGNORECASE)
|
|
264
|
+
m = re.search(r"\bFROM\s+([`\"\[]?[a-zA-Z0-9_\.]+(?:\"|\'|\]|`)?)", main_q, flags=re.IGNORECASE)
|
|
265
|
+
assert m is not None
|
|
266
|
+
detected = m.group(1).strip('`"[]')
|
|
267
|
+
assert detected == "recent", f"Expected target 'recent', got '{detected}'"
|
|
268
|
+
|
|
269
|
+
print(" [+] Table Quoting & CTE Export Target Detection: PASS")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_result_set_memory_limit():
|
|
273
|
+
print("[*] 9. Testing Result Set Memory Limit (Capped at 1,000 rows)...")
|
|
274
|
+
db = DatabaseEngine(":memory:")
|
|
275
|
+
db.conn.execute("CREATE TABLE big (id INTEGER PRIMARY KEY);")
|
|
276
|
+
db.conn.executemany("INSERT INTO big (id) VALUES (?);", [(i,) for i in range(1500)])
|
|
277
|
+
db.conn.commit()
|
|
278
|
+
|
|
279
|
+
res = db.execute_query("SELECT * FROM big;", max_rows=1000)
|
|
280
|
+
assert res["success"]
|
|
281
|
+
assert res["row_count"] == 1000, f"Expected 1000 rows capped, got {res['row_count']}"
|
|
282
|
+
assert res["truncated"] is True, "Expected truncated flag to be True"
|
|
283
|
+
|
|
284
|
+
print(" [+] Result Set Memory Limit: PASS")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main():
|
|
288
|
+
print("\n" + "=" * 60)
|
|
289
|
+
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.3)")
|
|
290
|
+
print("=" * 60)
|
|
291
|
+
|
|
292
|
+
test_safe_mode_and_comment_bypass()
|
|
293
|
+
test_database_engine_level_readonly()
|
|
294
|
+
test_sql_beautifier_preserves_literals()
|
|
295
|
+
test_sqlite_foreign_keys_enforced()
|
|
296
|
+
test_empty_sqlite_not_polluted()
|
|
297
|
+
test_returning_dml_classification()
|
|
298
|
+
test_exporter_json_types_and_dialect_sql()
|
|
299
|
+
test_table_quoting_and_cte_export_detection()
|
|
300
|
+
test_result_set_memory_limit()
|
|
301
|
+
|
|
302
|
+
print("\n" + "=" * 60)
|
|
303
|
+
print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
|
|
304
|
+
print("=" * 60 + "\n")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|