ghostbase-cli 0.1.3__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/PKG-INFO +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/app.py +31 -8
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/db_engine.py +221 -48
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/PKG-INFO +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/main.py +3 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/tests/test_vulnerabilities_fixed.py +168 -9
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/LICENSE +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/README.md +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/exporter.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/SOURCES.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/setup.cfg +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/tests/test_exhaustive.py +0 -0
- {ghostbase_cli-0.1.3 → ghostbase_cli-0.1.5}/tests/test_real_db.py +0 -0
|
@@ -314,7 +314,8 @@ class GhostBaseApp(App):
|
|
|
314
314
|
self.db_path = db_path or "sample.db"
|
|
315
315
|
self.populate_sample = populate_sample
|
|
316
316
|
self.db = DatabaseEngine(self.db_path)
|
|
317
|
-
self.
|
|
317
|
+
self._cli_read_only = bool(read_only)
|
|
318
|
+
self.safe_mode = bool(read_only)
|
|
318
319
|
self.last_columns: list = []
|
|
319
320
|
self.last_rows: list = []
|
|
320
321
|
self.last_query_table: str = None
|
|
@@ -334,15 +335,19 @@ class GhostBaseApp(App):
|
|
|
334
335
|
if self.db.db_type != "sqlite"
|
|
335
336
|
else os.path.basename(self.db_path)
|
|
336
337
|
)
|
|
337
|
-
|
|
338
|
-
|
|
338
|
+
if self._cli_read_only:
|
|
339
|
+
safe_text = "[SAFE: LOCKED]"
|
|
340
|
+
safe_class = ""
|
|
341
|
+
else:
|
|
342
|
+
safe_text = "[SAFE: ON]" if self.safe_mode else "[SAFE: OFF]"
|
|
343
|
+
safe_class = "" if self.safe_mode else "unsafe"
|
|
339
344
|
|
|
340
345
|
with Horizontal(id="header-bar"):
|
|
341
346
|
yield Label("GHOSTBASE", id="title-label")
|
|
342
347
|
yield Label(f"[{self.db.db_type_label()}]", id="db-type-label")
|
|
343
348
|
yield Label(db_label, id="db-name-label")
|
|
344
349
|
yield Label(safe_text, id="safe-badge", classes=safe_class)
|
|
345
|
-
yield Label("v0.1.
|
|
350
|
+
yield Label("v0.1.5", id="version-label")
|
|
346
351
|
|
|
347
352
|
with Horizontal(id="main-container"):
|
|
348
353
|
with Vertical(id="sidebar"):
|
|
@@ -389,13 +394,14 @@ class GhostBaseApp(App):
|
|
|
389
394
|
# Lifecycle
|
|
390
395
|
|
|
391
396
|
async def on_mount(self):
|
|
392
|
-
# Configure database engine level Read-Only mode if safe_mode is enabled (fail-closed)
|
|
397
|
+
# Configure database engine level Read-Only mode if safe_mode is enabled (strictly fail-closed)
|
|
393
398
|
if self.safe_mode:
|
|
394
399
|
try:
|
|
395
400
|
self.db.set_read_only(True)
|
|
396
401
|
except Exception as e:
|
|
397
|
-
self.
|
|
398
|
-
self.
|
|
402
|
+
self.notify(f"Security Alert: Failed to enforce Read-Only mode on database: {e}", severity="error")
|
|
403
|
+
self.exit(return_code=1)
|
|
404
|
+
return
|
|
399
405
|
|
|
400
406
|
tables = self.db.get_tables()
|
|
401
407
|
# Only populate demo tables when explicitly requested, in-memory, or default sample.db AND NOT in safe_mode
|
|
@@ -780,7 +786,7 @@ class GhostBaseApp(App):
|
|
|
780
786
|
def action_explain_query(self):
|
|
781
787
|
"""Execute 1-Key EXPLAIN Query Plan (Ctrl+P)"""
|
|
782
788
|
raw = self.query_one("#query-box", TextArea).text.strip()
|
|
783
|
-
lines = [l for l in raw.splitlines() if not l.strip().startswith("--")]
|
|
789
|
+
lines = [l for l in raw.splitlines() if not l.strip().startswith(("--", "#"))]
|
|
784
790
|
query = "\n".join(lines).strip()
|
|
785
791
|
if not query:
|
|
786
792
|
self.notify("No query to explain", severity="warning")
|
|
@@ -790,6 +796,19 @@ class GhostBaseApp(App):
|
|
|
790
796
|
status = self.query_one("#status-label", Label)
|
|
791
797
|
res_cnt = self.query_one("#results-count", Label)
|
|
792
798
|
|
|
799
|
+
# Central safety verification before EXPLAIN
|
|
800
|
+
statements = self.db.split_statements(query)
|
|
801
|
+
if len(statements) > 1:
|
|
802
|
+
status.update("[bold red]EXPLAIN ERROR[/] Multiple statements not allowed in EXPLAIN")
|
|
803
|
+
self.notify("EXPLAIN only supports a single SQL statement.", severity="error", timeout=4)
|
|
804
|
+
return
|
|
805
|
+
|
|
806
|
+
is_destructive, reason = self.db.check_safety(query, is_safe_mode=self.safe_mode)
|
|
807
|
+
if self.safe_mode and is_destructive:
|
|
808
|
+
status.update(f"[bold red]BLOCKED[/] {reason}")
|
|
809
|
+
self.notify(f"Cannot explain query in Safe Mode: {reason}", severity="error", timeout=5)
|
|
810
|
+
return
|
|
811
|
+
|
|
793
812
|
res = self.db.explain_query(query)
|
|
794
813
|
dt.clear(columns=True)
|
|
795
814
|
|
|
@@ -815,6 +834,10 @@ class GhostBaseApp(App):
|
|
|
815
834
|
|
|
816
835
|
def action_toggle_safe_mode(self):
|
|
817
836
|
"""Toggle Production Safe Mode (Ctrl+S) - Fail-closed"""
|
|
837
|
+
if getattr(self, "_cli_read_only", False):
|
|
838
|
+
self.notify("CLI --read-only flag is active. Safe Mode is locked and cannot be disabled.", severity="warning", timeout=4)
|
|
839
|
+
return
|
|
840
|
+
|
|
818
841
|
new_mode = not self.safe_mode
|
|
819
842
|
self._pending_destructive_query = None
|
|
820
843
|
self._pending_destructive_time = 0.0
|
|
@@ -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):
|
|
@@ -353,14 +353,38 @@ class DatabaseEngine:
|
|
|
353
353
|
|
|
354
354
|
# --- Query safety and formatting ---
|
|
355
355
|
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _mask_strings(sql: str) -> Tuple[str, List[str]]:
|
|
358
|
+
"""
|
|
359
|
+
Extract and mask string literals (data values):
|
|
360
|
+
- Plain untagged dollar-quotes: $$...$$
|
|
361
|
+
- Tagged dollar-quotes: $tag$...$tag$
|
|
362
|
+
- PostgreSQL E-strings: E'...'
|
|
363
|
+
- Standard single-quoted strings: '...'
|
|
364
|
+
"""
|
|
365
|
+
literals = []
|
|
366
|
+
def _repl(m):
|
|
367
|
+
idx = len(literals)
|
|
368
|
+
literals.append(m.group(0))
|
|
369
|
+
return f"___GB_STR_{idx}___"
|
|
370
|
+
|
|
371
|
+
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*')"
|
|
372
|
+
masked = re.sub(pattern, _repl, sql)
|
|
373
|
+
return masked, literals
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _strip_comments(sql: str) -> str:
|
|
377
|
+
"""Strip block comments /* ... */, line comments -- ..., and MySQL # ..."""
|
|
378
|
+
c = re.sub(r"/\*[\s\S]*?\*/", " ", sql)
|
|
379
|
+
c = re.sub(r"--[^\r\n]*", " ", c)
|
|
380
|
+
c = re.sub(r"#[^\r\n]*", " ", c)
|
|
381
|
+
return c
|
|
382
|
+
|
|
356
383
|
@staticmethod
|
|
357
384
|
def _mask_strings_and_literals(sql: str) -> Tuple[str, List[str]]:
|
|
358
385
|
"""
|
|
359
386
|
Extract and mask all string literals and quoted identifiers:
|
|
360
|
-
|
|
361
|
-
- Standard single-quoted strings: '...' (with '' escaping)
|
|
362
|
-
- Standard double-quoted strings / identifiers: "..."
|
|
363
|
-
- MySQL backtick identifiers: `...`
|
|
387
|
+
Used by SQL formatter to preserve both literal values and identifier cases.
|
|
364
388
|
"""
|
|
365
389
|
literals = []
|
|
366
390
|
def _repl(m):
|
|
@@ -368,36 +392,78 @@ class DatabaseEngine:
|
|
|
368
392
|
literals.append(m.group(0))
|
|
369
393
|
return f" ___GB_LIT_{idx}___ "
|
|
370
394
|
|
|
371
|
-
|
|
372
|
-
# 1. Plain untagged dollar-quotes: $$...$$
|
|
373
|
-
# 2. Tagged dollar-quotes with strict matching backreference: $tag$...$tag$
|
|
374
|
-
# 3. Standard single-quoted strings: '...'
|
|
375
|
-
# 4. Standard double-quoted strings/identifiers: "..."
|
|
376
|
-
# 5. MySQL backticks: `...`
|
|
377
|
-
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|'(?:''|[^'])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`)"
|
|
395
|
+
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
|
|
378
396
|
masked = re.sub(pattern, _repl, sql)
|
|
379
397
|
return masked, literals
|
|
380
398
|
|
|
381
|
-
|
|
399
|
+
@classmethod
|
|
400
|
+
def split_statements(cls, sql: str) -> List[str]:
|
|
382
401
|
"""
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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.
|
|
402
|
+
Split a multi-statement SQL script into individual statements by semicolon.
|
|
403
|
+
Guarantees that semicolons inside string literals, dollar quotes, comments,
|
|
404
|
+
and quoted identifiers do not cause incorrect statement boundaries.
|
|
388
405
|
"""
|
|
389
|
-
#
|
|
390
|
-
|
|
406
|
+
# Mask both strings and quoted identifiers so semicolons inside them are preserved
|
|
407
|
+
tokens = []
|
|
408
|
+
def _repl_tok(m):
|
|
409
|
+
idx = len(tokens)
|
|
410
|
+
tokens.append(m.group(0))
|
|
411
|
+
return f"___GB_TOK_{idx}___"
|
|
412
|
+
|
|
413
|
+
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
|
|
414
|
+
masked = re.sub(pattern, _repl_tok, sql)
|
|
415
|
+
clean = cls._strip_comments(masked)
|
|
416
|
+
parts = clean.split(";")
|
|
417
|
+
stmts = []
|
|
418
|
+
for p in parts:
|
|
419
|
+
p_str = p.strip()
|
|
420
|
+
if p_str:
|
|
421
|
+
for idx, tok in enumerate(tokens):
|
|
422
|
+
p_str = p_str.replace(f"___GB_TOK_{idx}___", tok)
|
|
423
|
+
stmts.append(p_str)
|
|
424
|
+
return stmts
|
|
391
425
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _normalize_identifiers_for_safety(sql: str) -> str:
|
|
428
|
+
"""
|
|
429
|
+
Normalize quoted identifiers ("set_config", `setval`, [col]) for safety checks:
|
|
430
|
+
- Quoted function/object names (e.g. "set_config", "setval", "delete") are unquoted
|
|
431
|
+
so that identifier quotes cannot conceal restricted keywords or functions.
|
|
432
|
+
- If an identifier is literally "WHERE", it represents a column/alias name and CANNOT
|
|
433
|
+
act as a SQL clause keyword; it is masked to ___GB_ID_WHERE___ so it does not
|
|
434
|
+
falsely satisfy the WHERE requirement of UPDATE/DELETE.
|
|
435
|
+
"""
|
|
436
|
+
def _repl_ident(m):
|
|
437
|
+
raw = m.group(0)
|
|
438
|
+
inner = raw[1:-1]
|
|
439
|
+
if raw.startswith('"'):
|
|
440
|
+
inner = inner.replace('""', '"')
|
|
441
|
+
elif raw.startswith('`'):
|
|
442
|
+
inner = inner.replace('``', '`')
|
|
443
|
+
elif raw.startswith('['):
|
|
444
|
+
inner = inner.replace(']]', ']')
|
|
445
|
+
|
|
446
|
+
if inner.strip().upper() == "WHERE":
|
|
447
|
+
return " ___GB_ID_WHERE___ "
|
|
448
|
+
return f" {inner} "
|
|
449
|
+
|
|
450
|
+
ident_pattern = r"(\"(?:\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
|
|
451
|
+
return re.sub(ident_pattern, _repl_ident, sql)
|
|
452
|
+
|
|
453
|
+
def _check_single_statement_safety(self, stmt: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
|
|
454
|
+
"""Evaluate safety on an individual, isolated SQL statement."""
|
|
455
|
+
# 1. Mask data literals (single quotes, dollar-quotes, E-strings)
|
|
456
|
+
masked, _ = self._mask_strings(stmt)
|
|
457
|
+
|
|
458
|
+
# 2. Strip comments
|
|
459
|
+
q_no_comments = self._strip_comments(masked)
|
|
460
|
+
|
|
461
|
+
# 3. Normalize identifiers (unquote "set_config", protect "where" columns)
|
|
462
|
+
q_norm = self._normalize_identifiers_for_safety(q_no_comments).strip()
|
|
463
|
+
if not q_norm:
|
|
398
464
|
return False, ""
|
|
399
465
|
|
|
400
|
-
q_upper =
|
|
466
|
+
q_upper = q_norm.upper()
|
|
401
467
|
|
|
402
468
|
# SAFE MODE: STRICT READ-ONLY ENFORCEMENT
|
|
403
469
|
if is_safe_mode:
|
|
@@ -413,14 +479,23 @@ class DatabaseEngine:
|
|
|
413
479
|
(r"\bGRANT\b", "GRANT statement"),
|
|
414
480
|
(r"\bREVOKE\b", "REVOKE statement"),
|
|
415
481
|
(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
482
|
(r"\bSET\b", "SET configuration change"),
|
|
420
483
|
(r"\bRESET\b", "RESET configuration change"),
|
|
421
484
|
(r"\bDO\b", "DO procedural block"),
|
|
422
485
|
(r"\bCALL\b", "CALL stored procedure"),
|
|
423
486
|
(r"\bMERGE\b", "MERGE statement"),
|
|
487
|
+
(r"\bSET_CONFIG\b", "SET_CONFIG parameter modification"),
|
|
488
|
+
(r"\bSETVAL\b", "SETVAL sequence mutation"),
|
|
489
|
+
(r"\bNEXTVAL\b", "NEXTVAL sequence advance"),
|
|
490
|
+
(r"\bCURRVAL\b", "CURRVAL sequence check"),
|
|
491
|
+
(r"\bLASTVAL\b", "LASTVAL sequence check"),
|
|
492
|
+
(r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
|
|
493
|
+
(r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
|
|
494
|
+
(r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
|
|
495
|
+
(r"\bLO_EXPORT\b", "LO_EXPORT filesystem write"),
|
|
496
|
+
(r"\bDBLINK\b", "DBLINK external query execution"),
|
|
497
|
+
(r"\bINTO\s+OUTFILE\b", "INTO OUTFILE export"),
|
|
498
|
+
(r"\bINTO\s+DUMPFILE\b", "INTO DUMPFILE export"),
|
|
424
499
|
]
|
|
425
500
|
for pattern, desc in write_keywords:
|
|
426
501
|
if re.search(pattern, q_upper):
|
|
@@ -431,7 +506,7 @@ class DatabaseEngine:
|
|
|
431
506
|
|
|
432
507
|
return False, ""
|
|
433
508
|
|
|
434
|
-
# NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops)
|
|
509
|
+
# NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops per statement)
|
|
435
510
|
if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW)\b", q_upper):
|
|
436
511
|
return True, "DROP operation detected"
|
|
437
512
|
if re.search(r"\bTRUNCATE\b", q_upper):
|
|
@@ -445,20 +520,56 @@ class DatabaseEngine:
|
|
|
445
520
|
|
|
446
521
|
return False, ""
|
|
447
522
|
|
|
448
|
-
def
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
523
|
+
def check_safety(self, query: str, is_safe_mode: bool = False) -> Tuple[bool, str]:
|
|
524
|
+
"""
|
|
525
|
+
Check script safety across all statements.
|
|
526
|
+
Multi-statement queries are evaluated individually: if ANY statement is destructive
|
|
527
|
+
or violates Safe Mode, the entire batch is flagged.
|
|
528
|
+
"""
|
|
529
|
+
statements = self.split_statements(query)
|
|
530
|
+
if not statements:
|
|
531
|
+
return False, ""
|
|
454
532
|
|
|
455
|
-
|
|
533
|
+
for stmt in statements:
|
|
534
|
+
is_dest, reason = self._check_single_statement_safety(stmt, is_safe_mode=is_safe_mode)
|
|
535
|
+
if is_dest:
|
|
536
|
+
return True, reason
|
|
537
|
+
|
|
538
|
+
return False, ""
|
|
539
|
+
|
|
540
|
+
def explain_query(self, query: str) -> Dict[str, Any]:
|
|
541
|
+
"""
|
|
542
|
+
Explain query execution plan.
|
|
543
|
+
Strictly allows only a single read statement. Multi-statement batches or destructive
|
|
544
|
+
operations are strictly rejected to prevent execution bypasses.
|
|
545
|
+
"""
|
|
546
|
+
statements = self.split_statements(query)
|
|
547
|
+
if not statements:
|
|
456
548
|
return {"success": False, "error": "Query is empty"}
|
|
549
|
+
if len(statements) > 1:
|
|
550
|
+
return {
|
|
551
|
+
"success": False,
|
|
552
|
+
"error": "EXPLAIN only supports a single SQL statement. Multiple statements are not permitted.",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
single_q = statements[0].strip().rstrip(";")
|
|
556
|
+
|
|
557
|
+
# Validate safety on the single statement to be explained
|
|
558
|
+
is_dest, reason = self.check_safety(single_q, is_safe_mode=False)
|
|
559
|
+
|
|
560
|
+
# Detect EXPLAIN ANALYZE on destructive queries (PostgreSQL executes the query under ANALYZE)
|
|
561
|
+
masked, _ = self._mask_strings(single_q)
|
|
562
|
+
clean = self._strip_comments(masked).strip().upper()
|
|
563
|
+
if re.search(r"\b(ANALYZE|EXECUTE)\b", clean) and is_dest:
|
|
564
|
+
return {
|
|
565
|
+
"success": False,
|
|
566
|
+
"error": f"EXPLAIN with execution side-effects on destructive statements is blocked: {reason}",
|
|
567
|
+
}
|
|
457
568
|
|
|
458
569
|
if self.db_type == "sqlite":
|
|
459
|
-
explain_sql = f"EXPLAIN QUERY PLAN {
|
|
570
|
+
explain_sql = f"EXPLAIN QUERY PLAN {single_q};"
|
|
460
571
|
else:
|
|
461
|
-
explain_sql = f"EXPLAIN {
|
|
572
|
+
explain_sql = f"EXPLAIN {single_q};"
|
|
462
573
|
return self.execute_query(explain_sql)
|
|
463
574
|
|
|
464
575
|
@staticmethod
|
|
@@ -474,7 +585,7 @@ class DatabaseEngine:
|
|
|
474
585
|
sql_lines = []
|
|
475
586
|
for line in lines:
|
|
476
587
|
stripped = line.strip()
|
|
477
|
-
if stripped.startswith("--"):
|
|
588
|
+
if stripped.startswith(("--", "#")):
|
|
478
589
|
comment_lines.append(stripped)
|
|
479
590
|
else:
|
|
480
591
|
sql_lines.append(line)
|
|
@@ -521,6 +632,33 @@ class DatabaseEngine:
|
|
|
521
632
|
result_parts.append(sql_text.strip())
|
|
522
633
|
return "\n".join(result_parts)
|
|
523
634
|
|
|
635
|
+
@classmethod
|
|
636
|
+
def is_mysql_select_needing_limit(cls, query: str) -> bool:
|
|
637
|
+
"""
|
|
638
|
+
Check if a query is a top-level SELECT/WITH in MySQL that lacks a top-level LIMIT clause.
|
|
639
|
+
Guarantees that SHOW, DESCRIBE, EXPLAIN, and subqueries with LIMIT are handled correctly.
|
|
640
|
+
"""
|
|
641
|
+
masked, _ = cls._mask_strings(query)
|
|
642
|
+
clean = cls._strip_comments(masked).strip()
|
|
643
|
+
|
|
644
|
+
# Must start with SELECT or WITH
|
|
645
|
+
if not re.match(r"^(SELECT|WITH)\b", clean, flags=re.IGNORECASE):
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
# Check if there is a top-level LIMIT (at parenthesis depth 0)
|
|
649
|
+
depth = 0
|
|
650
|
+
tokens = re.split(r"(\(|\)|\bLIMIT\b)", clean, flags=re.IGNORECASE)
|
|
651
|
+
for token in tokens:
|
|
652
|
+
if token == "(":
|
|
653
|
+
depth += 1
|
|
654
|
+
elif token == ")":
|
|
655
|
+
if depth > 0:
|
|
656
|
+
depth -= 1
|
|
657
|
+
elif token.upper() == "LIMIT":
|
|
658
|
+
if depth == 0:
|
|
659
|
+
return False # Already has a top-level LIMIT
|
|
660
|
+
return True
|
|
661
|
+
|
|
524
662
|
# --- Query execution ---
|
|
525
663
|
|
|
526
664
|
def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
|
|
@@ -530,23 +668,58 @@ class DatabaseEngine:
|
|
|
530
668
|
# Check if query is DML/DDL write operation (even if it has RETURNING)
|
|
531
669
|
masked, _ = self._mask_strings_and_literals(query)
|
|
532
670
|
clean_q = re.sub(r"/\*[\s\S]*?\*/", " ", masked)
|
|
533
|
-
clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
|
|
671
|
+
clean_q = re.sub(r"--[^\r\n]*", " ", clean_q)
|
|
672
|
+
clean_q = re.sub(r"#[^\r\n]*", " ", clean_q).strip().upper()
|
|
534
673
|
is_write_dml = bool(
|
|
535
674
|
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
675
|
)
|
|
537
676
|
|
|
538
|
-
# Use server-side
|
|
677
|
+
# PostgreSQL streaming: Use server-side named cursor to prevent pulling entire table into client RAM
|
|
539
678
|
if self.db_type == "postgresql" and not is_write_dml:
|
|
540
679
|
stream_name = f"gb_stream_{int(time.perf_counter() * 1000000)}"
|
|
680
|
+
# Disable autocommit temporarily for named cursor creation in psycopg2
|
|
681
|
+
self.conn.autocommit = False
|
|
541
682
|
try:
|
|
542
683
|
cur = self.conn.cursor(name=stream_name)
|
|
543
684
|
cur.itersize = max_rows + 1
|
|
544
|
-
|
|
545
|
-
cur
|
|
546
|
-
|
|
547
|
-
|
|
685
|
+
cur.execute(query)
|
|
686
|
+
if cur.description:
|
|
687
|
+
columns = [d[0] for d in cur.description]
|
|
688
|
+
raw = cur.fetchmany(max_rows + 1)
|
|
689
|
+
truncated = len(raw) > max_rows
|
|
690
|
+
display_rows = raw[:max_rows]
|
|
691
|
+
rows = [list(r.values()) if isinstance(r, dict) else list(r) for r in display_rows]
|
|
692
|
+
cur.close()
|
|
693
|
+
self.conn.commit() # Cleanly close read transaction without leaving idle connection
|
|
694
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
695
|
+
return {
|
|
696
|
+
"success": True,
|
|
697
|
+
"columns": columns,
|
|
698
|
+
"rows": rows,
|
|
699
|
+
"row_count": len(rows),
|
|
700
|
+
"affected_rows": 0,
|
|
701
|
+
"truncated": truncated,
|
|
702
|
+
"elapsed_ms": round(elapsed_ms, 2),
|
|
703
|
+
"is_select": True,
|
|
704
|
+
"is_returning": False,
|
|
705
|
+
}
|
|
706
|
+
except Exception as e:
|
|
707
|
+
try:
|
|
708
|
+
self.conn.rollback()
|
|
709
|
+
except Exception:
|
|
710
|
+
pass
|
|
711
|
+
raise e
|
|
712
|
+
finally:
|
|
713
|
+
self.conn.autocommit = True
|
|
714
|
+
|
|
715
|
+
# MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
|
|
716
|
+
exec_query = query
|
|
717
|
+
if self.db_type == "mysql" and not is_write_dml:
|
|
718
|
+
if self.is_mysql_select_needing_limit(query):
|
|
719
|
+
exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
|
|
548
720
|
|
|
549
|
-
cur.
|
|
721
|
+
cur = self.conn.cursor()
|
|
722
|
+
cur.execute(exec_query)
|
|
550
723
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
551
724
|
|
|
552
725
|
if cur.description:
|
|
@@ -112,6 +112,9 @@ def main():
|
|
|
112
112
|
try:
|
|
113
113
|
app = GhostBaseApp(db_path=conn, read_only=read_only)
|
|
114
114
|
app.run()
|
|
115
|
+
ret = getattr(app, "return_code", 0)
|
|
116
|
+
if isinstance(ret, int) and ret != 0:
|
|
117
|
+
sys.exit(ret)
|
|
115
118
|
except Exception as e:
|
|
116
119
|
print(f"\n[GhostBase Error] Failed to connect to database: {e}\n")
|
|
117
120
|
sys.exit(1)
|
|
@@ -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.5"
|
|
8
8
|
description = "A fast, keyboard-driven terminal database studio for SQLite, PostgreSQL & MySQL"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "4yk5", email = "contact.4yk5@proton.me" }]
|
|
@@ -46,6 +46,16 @@ def test_safe_mode_and_comment_bypass():
|
|
|
46
46
|
is_dest, r = db.check_safety(mismatched_tag_query, is_safe_mode=False)
|
|
47
47
|
assert is_dest, f"VULNERABILITY: Dollar-quoted tag mismatch bypassed WHERE check: {r}"
|
|
48
48
|
|
|
49
|
+
# Postgres E'...' string with backslash escaped quote
|
|
50
|
+
e_string_query = "UPDATE users SET role = E'foo\\' WHERE false';"
|
|
51
|
+
is_dest, r = db.check_safety(e_string_query, is_safe_mode=False)
|
|
52
|
+
assert is_dest, f"VULNERABILITY: PostgreSQL E-string with escaped quote bypassed WHERE check: {r}"
|
|
53
|
+
|
|
54
|
+
# MySQL # comment bypass attempt
|
|
55
|
+
mysql_comment_query = "UPDATE users SET role = 'Member' # WHERE id = 1\n;"
|
|
56
|
+
is_dest, r = db.check_safety(mysql_comment_query, is_safe_mode=False)
|
|
57
|
+
assert is_dest, f"VULNERABILITY: MySQL # comment containing WHERE fooled check_safety: {r}"
|
|
58
|
+
|
|
49
59
|
# Safe Mode STRICT read-only checks
|
|
50
60
|
safe_queries = [
|
|
51
61
|
"SELECT * FROM users WHERE id = 5;",
|
|
@@ -57,7 +67,7 @@ def test_safe_mode_and_comment_bypass():
|
|
|
57
67
|
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
58
68
|
assert not is_dest, f"Safe query wrongly blocked in Safe Mode: {q} ({r})"
|
|
59
69
|
|
|
60
|
-
# Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, and
|
|
70
|
+
# Safe Mode must strictly block DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions
|
|
61
71
|
unsafe_queries = [
|
|
62
72
|
("UPDATE users SET role = 'Admin' WHERE id = 5;", "UPDATE"),
|
|
63
73
|
("INSERT INTO users (name) VALUES ('Test');", "INSERT"),
|
|
@@ -67,6 +77,9 @@ def test_safe_mode_and_comment_bypass():
|
|
|
67
77
|
("ALTER TABLE users ADD COLUMN age INT;", "ALTER"),
|
|
68
78
|
("CREATE TABLE test (id INT);", "CREATE"),
|
|
69
79
|
("SELECT nextval('users_id_seq');", "NEXTVAL"),
|
|
80
|
+
("SELECT setval('users_id_seq', 1, false);", "SETVAL"),
|
|
81
|
+
("SELECT set_config('default_transaction_read_only', 'off', false);", "SET_CONFIG"),
|
|
82
|
+
("SELECT pg_terminate_backend(1234);", "PG_TERMINATE_BACKEND"),
|
|
70
83
|
("SELECT * INTO OUTFILE '/tmp/f' FROM users;", "INTO OUTFILE"),
|
|
71
84
|
("SET default_transaction_read_only = OFF;", "SET"),
|
|
72
85
|
("RESET ALL;", "RESET"),
|
|
@@ -156,22 +169,27 @@ def test_sqlite_foreign_keys_enforced():
|
|
|
156
169
|
|
|
157
170
|
|
|
158
171
|
def test_empty_sqlite_not_polluted():
|
|
159
|
-
print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode...")
|
|
172
|
+
print("[*] 5. Testing Empty SQLite Database Guard in Read-Only Mode (with actual on_mount)...")
|
|
173
|
+
import asyncio
|
|
160
174
|
from app import GhostBaseApp
|
|
161
175
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
162
176
|
empty_db_path = f.name
|
|
163
177
|
|
|
164
|
-
|
|
165
|
-
# Launch in read-only / safe mode
|
|
178
|
+
async def _run():
|
|
166
179
|
app = GhostBaseApp(db_path=empty_db_path, read_only=True, populate_sample=True)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
async with app.run_test() as pilot:
|
|
181
|
+
await pilot.pause(0.1)
|
|
182
|
+
tables = app.db.get_tables()
|
|
183
|
+
assert len(tables) == 0, f"VULNERABILITY: Sample tables were created during --read-only launch: {tables}"
|
|
184
|
+
assert app.safe_mode is True
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
asyncio.run(_run())
|
|
170
188
|
finally:
|
|
171
189
|
if os.path.exists(empty_db_path):
|
|
172
190
|
os.remove(empty_db_path)
|
|
173
191
|
|
|
174
|
-
print(" [+] Empty Database Integrity in Read-Only: PASS")
|
|
192
|
+
print(" [+] Empty Database Integrity in Read-Only (on_mount verified): PASS")
|
|
175
193
|
|
|
176
194
|
|
|
177
195
|
def test_returning_dml_classification():
|
|
@@ -284,9 +302,145 @@ def test_result_set_memory_limit():
|
|
|
284
302
|
print(" [+] Result Set Memory Limit: PASS")
|
|
285
303
|
|
|
286
304
|
|
|
305
|
+
def test_quoted_identifier_safety():
|
|
306
|
+
print("[*] 10. Testing Quoted Identifier Safety (Safe Mode & WHERE Column Guards)...")
|
|
307
|
+
db = DatabaseEngine(":memory:")
|
|
308
|
+
|
|
309
|
+
# Safe Mode: Quoted set_config, setval, nextval must NOT bypass detection
|
|
310
|
+
quoted_unsafe_queries = [
|
|
311
|
+
'SELECT pg_catalog."set_config"(\'default_transaction_read_only\', \'off\', false);',
|
|
312
|
+
'SELECT "setval"(\'users_id_seq\', 1, false);',
|
|
313
|
+
'SELECT "nextval"(\'users_id_seq\');',
|
|
314
|
+
'SELECT `set_config`(\'default_transaction_read_only\', \'off\', false);',
|
|
315
|
+
'SELECT [setval](\'users_id_seq\', 1);',
|
|
316
|
+
'SELECT "pg_terminate_backend"(1234);',
|
|
317
|
+
]
|
|
318
|
+
for q in quoted_unsafe_queries:
|
|
319
|
+
is_dest, r = db.check_safety(q, is_safe_mode=True)
|
|
320
|
+
assert is_dest, f"VULNERABILITY: Quoted function bypassed Safe Mode: {q}"
|
|
321
|
+
assert "Safe Mode is active" in r
|
|
322
|
+
|
|
323
|
+
# Normal Mode: Column named "where" or `where` must NOT masquerade as WHERE clause
|
|
324
|
+
col_where_queries = [
|
|
325
|
+
'UPDATE users SET "where" = 1;',
|
|
326
|
+
'UPDATE users SET `where` = 1;',
|
|
327
|
+
'UPDATE users SET [where] = 1;',
|
|
328
|
+
'DELETE FROM users WHERE false = false;', # Real WHERE -> safe
|
|
329
|
+
]
|
|
330
|
+
# First 3 must be detected as UPDATE without WHERE
|
|
331
|
+
for q in col_where_queries[:3]:
|
|
332
|
+
is_dest, r = db.check_safety(q, is_safe_mode=False)
|
|
333
|
+
assert is_dest, f"VULNERABILITY: Quoted 'where' column bypassed WHERE check: {q}"
|
|
334
|
+
assert "UPDATE without WHERE" in r
|
|
335
|
+
|
|
336
|
+
# Real WHERE must pass
|
|
337
|
+
is_dest, r = db.check_safety(col_where_queries[3], is_safe_mode=False)
|
|
338
|
+
assert not is_dest, f"Valid WHERE clause falsely flagged: {col_where_queries[3]}"
|
|
339
|
+
|
|
340
|
+
print(" [+] Quoted Identifier Safety: PASS")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_multi_statement_safety():
|
|
344
|
+
print("[*] 11. Testing Multi-Statement SQL Safety Evaluation...")
|
|
345
|
+
db = DatabaseEngine(":memory:")
|
|
346
|
+
|
|
347
|
+
# Multi-statement injection: UPDATE without WHERE followed by SELECT with WHERE
|
|
348
|
+
multi_bypass = "UPDATE users SET role = 'Admin'; SELECT 1 WHERE true;"
|
|
349
|
+
is_dest, r = db.check_safety(multi_bypass, is_safe_mode=False)
|
|
350
|
+
assert is_dest, "VULNERABILITY: Multi-statement UPDATE without WHERE bypassed safety!"
|
|
351
|
+
assert "UPDATE without WHERE" in r
|
|
352
|
+
|
|
353
|
+
# Multi-statement: SELECT followed by unconditional DELETE
|
|
354
|
+
multi_delete = "SELECT 1; DELETE FROM users;"
|
|
355
|
+
is_dest, r = db.check_safety(multi_delete, is_safe_mode=False)
|
|
356
|
+
assert is_dest, "VULNERABILITY: Multi-statement DELETE without WHERE bypassed safety!"
|
|
357
|
+
assert "DELETE without WHERE" in r
|
|
358
|
+
|
|
359
|
+
# Safe multi-statement: Both statements have valid WHERE clauses
|
|
360
|
+
multi_safe = "UPDATE users SET role = 'Admin' WHERE id = 1; SELECT 1 WHERE true;"
|
|
361
|
+
is_dest, r = db.check_safety(multi_safe, is_safe_mode=False)
|
|
362
|
+
assert not is_dest, f"Safe multi-statement wrongly flagged: {multi_safe} ({r})"
|
|
363
|
+
|
|
364
|
+
# Safe Mode multi-statement: Any write in any statement must trigger Safe Mode block
|
|
365
|
+
safe_mode_multi = "SELECT 1; INSERT INTO audit (msg) VALUES ('ok');"
|
|
366
|
+
is_dest, r = db.check_safety(safe_mode_multi, is_safe_mode=True)
|
|
367
|
+
assert is_dest, "VULNERABILITY: INSERT in multi-statement bypassed Safe Mode!"
|
|
368
|
+
assert "Safe Mode is active" in r
|
|
369
|
+
|
|
370
|
+
# Statement splitter preserves semicolons inside strings and comments
|
|
371
|
+
complex_script = "SELECT 'one;two;three' AS s; -- comment; with; semicolons\nSELECT 2;"
|
|
372
|
+
stmts = db.split_statements(complex_script)
|
|
373
|
+
assert len(stmts) == 2, f"Expected 2 statements, got {len(stmts)}: {stmts}"
|
|
374
|
+
assert stmts[0] == "SELECT 'one;two;three' AS s"
|
|
375
|
+
assert stmts[1] == "SELECT 2"
|
|
376
|
+
|
|
377
|
+
print(" [+] Multi-Statement Safety: PASS")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_explain_query_safety():
|
|
381
|
+
print("[*] 12. Testing EXPLAIN Query Plan Safety (No Multi-Statement Injection)...")
|
|
382
|
+
db = DatabaseEngine(":memory:")
|
|
383
|
+
db.conn.execute("CREATE TABLE users (id INT, role TEXT);")
|
|
384
|
+
db.conn.execute("INSERT INTO users VALUES (1, 'User');")
|
|
385
|
+
db.conn.commit()
|
|
386
|
+
|
|
387
|
+
# Attempt multi-statement injection via EXPLAIN
|
|
388
|
+
injection = "SELECT 1; DELETE FROM users;"
|
|
389
|
+
res = db.explain_query(injection)
|
|
390
|
+
assert not res["success"], "VULNERABILITY: EXPLAIN allowed multi-statement execution!"
|
|
391
|
+
assert "single SQL statement" in res["error"]
|
|
392
|
+
|
|
393
|
+
# Verify that users table was NOT modified
|
|
394
|
+
cur = db.conn.cursor()
|
|
395
|
+
cur.execute("SELECT COUNT(*) FROM users;")
|
|
396
|
+
assert cur.fetchone()[0] == 1, "VULNERABILITY: Table data was modified during EXPLAIN injection!"
|
|
397
|
+
|
|
398
|
+
# Normal valid single EXPLAIN
|
|
399
|
+
valid_res = db.explain_query("SELECT * FROM users WHERE id = 1;")
|
|
400
|
+
assert valid_res["success"], f"Valid EXPLAIN failed: {valid_res.get('error')}"
|
|
401
|
+
|
|
402
|
+
print(" [+] EXPLAIN Query Plan Safety: PASS")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_mysql_auto_limit_logic():
|
|
406
|
+
print("[*] 13. Testing MySQL Auto-LIMIT Logic (Top-Level SELECT vs Non-SELECT/Subqueries)...")
|
|
407
|
+
test_cases = [
|
|
408
|
+
("SHOW TABLES;", False),
|
|
409
|
+
("SHOW DATABASES;", False),
|
|
410
|
+
("DESCRIBE users;", False),
|
|
411
|
+
("DESC users;", False),
|
|
412
|
+
("EXPLAIN SELECT * FROM users;", False),
|
|
413
|
+
("SELECT * FROM users;", True),
|
|
414
|
+
("SELECT * FROM users LIMIT 10;", False),
|
|
415
|
+
("SELECT * FROM huge WHERE id IN (SELECT id FROM t LIMIT 10);", True),
|
|
416
|
+
("WITH cte AS (SELECT 1 AS n LIMIT 5) SELECT * FROM cte;", True),
|
|
417
|
+
("WITH cte AS (SELECT 1 AS n) SELECT * FROM cte LIMIT 20;", False),
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
for q, expected in test_cases:
|
|
421
|
+
res = DatabaseEngine.is_mysql_select_needing_limit(q)
|
|
422
|
+
assert res == expected, f"MySQL LIMIT detection failed for: {q} (expected {expected}, got {res})"
|
|
423
|
+
|
|
424
|
+
print(" [+] MySQL Auto-LIMIT Logic: PASS")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_cli_readonly_lock():
|
|
428
|
+
print("[*] 14. Testing CLI --read-only Immutable Lock...")
|
|
429
|
+
from app import GhostBaseApp
|
|
430
|
+
app = GhostBaseApp(db_path=":memory:", read_only=True)
|
|
431
|
+
assert app.safe_mode is True, "Safe mode should be initialized to True"
|
|
432
|
+
assert app._cli_read_only is True, "_cli_read_only flag should be True"
|
|
433
|
+
|
|
434
|
+
# Attempt to toggle safe mode off
|
|
435
|
+
app.action_toggle_safe_mode()
|
|
436
|
+
assert app.safe_mode is True, "VULNERABILITY: CLI --read-only was bypassed via action_toggle_safe_mode!"
|
|
437
|
+
|
|
438
|
+
print(" [+] CLI --read-only Immutable Lock: PASS")
|
|
439
|
+
|
|
440
|
+
|
|
287
441
|
def main():
|
|
288
442
|
print("\n" + "=" * 60)
|
|
289
|
-
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.
|
|
443
|
+
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.5)")
|
|
290
444
|
print("=" * 60)
|
|
291
445
|
|
|
292
446
|
test_safe_mode_and_comment_bypass()
|
|
@@ -298,6 +452,11 @@ def main():
|
|
|
298
452
|
test_exporter_json_types_and_dialect_sql()
|
|
299
453
|
test_table_quoting_and_cte_export_detection()
|
|
300
454
|
test_result_set_memory_limit()
|
|
455
|
+
test_quoted_identifier_safety()
|
|
456
|
+
test_multi_statement_safety()
|
|
457
|
+
test_explain_query_safety()
|
|
458
|
+
test_mysql_auto_limit_logic()
|
|
459
|
+
test_cli_readonly_lock()
|
|
301
460
|
|
|
302
461
|
print("\n" + "=" * 60)
|
|
303
462
|
print("ALL ANALYST VULNERABILITIES VERIFIED AND 100% PASSING!")
|
|
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
|