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