ghostbase-cli 0.1.4__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.4 → ghostbase_cli-0.1.5}/PKG-INFO +1 -1
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/app.py +27 -5
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/db_engine.py +171 -40
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/PKG-INFO +1 -1
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/main.py +3 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/pyproject.toml +1 -1
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/tests/test_vulnerabilities_fixed.py +142 -1
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/LICENSE +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/README.md +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/exporter.py +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/SOURCES.txt +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/dependency_links.txt +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/entry_points.txt +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/requires.txt +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/ghostbase_cli.egg-info/top_level.txt +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/setup.cfg +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/tests/test_all.py +0 -0
- {ghostbase_cli-0.1.4 → ghostbase_cli-0.1.5}/tests/test_exhaustive.py +0 -0
- {ghostbase_cli-0.1.4 → 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"):
|
|
@@ -781,7 +786,7 @@ class GhostBaseApp(App):
|
|
|
781
786
|
def action_explain_query(self):
|
|
782
787
|
"""Execute 1-Key EXPLAIN Query Plan (Ctrl+P)"""
|
|
783
788
|
raw = self.query_one("#query-box", TextArea).text.strip()
|
|
784
|
-
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(("--", "#"))]
|
|
785
790
|
query = "\n".join(lines).strip()
|
|
786
791
|
if not query:
|
|
787
792
|
self.notify("No query to explain", severity="warning")
|
|
@@ -791,6 +796,19 @@ class GhostBaseApp(App):
|
|
|
791
796
|
status = self.query_one("#status-label", Label)
|
|
792
797
|
res_cnt = self.query_one("#results-count", Label)
|
|
793
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
|
+
|
|
794
812
|
res = self.db.explain_query(query)
|
|
795
813
|
dt.clear(columns=True)
|
|
796
814
|
|
|
@@ -816,6 +834,10 @@ class GhostBaseApp(App):
|
|
|
816
834
|
|
|
817
835
|
def action_toggle_safe_mode(self):
|
|
818
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
|
+
|
|
819
841
|
new_mode = not self.safe_mode
|
|
820
842
|
self._pending_destructive_query = None
|
|
821
843
|
self._pending_destructive_time = 0.0
|
|
@@ -353,16 +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
|
-
- Tagged dollar-quotes with strict matching backreference \2: $tag$...$tag$
|
|
362
|
-
- PostgreSQL E-strings: E'...' (supporting backslash escapes \')
|
|
363
|
-
- Standard single-quoted strings: '...' (supporting \' and '')
|
|
364
|
-
- Standard double-quoted strings / identifiers: "..."
|
|
365
|
-
- MySQL backtick identifiers: `...`
|
|
387
|
+
Used by SQL formatter to preserve both literal values and identifier cases.
|
|
366
388
|
"""
|
|
367
389
|
literals = []
|
|
368
390
|
def _repl(m):
|
|
@@ -370,31 +392,78 @@ class DatabaseEngine:
|
|
|
370
392
|
literals.append(m.group(0))
|
|
371
393
|
return f" ___GB_LIT_{idx}___ "
|
|
372
394
|
|
|
373
|
-
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])
|
|
395
|
+
pattern = r"(\$\$[\s\S]*?\$\$|\$([a-zA-Z_]\w*)\$[\s\S]*?\$\2\$|E'(?:\\.|''|[^'\\])*'|'(?:\\.|''|[^'\\])*'|\"(?:\\.|\"\"|[^\"])*\"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\])"
|
|
374
396
|
masked = re.sub(pattern, _repl, sql)
|
|
375
397
|
return masked, literals
|
|
376
398
|
|
|
377
|
-
|
|
399
|
+
@classmethod
|
|
400
|
+
def split_statements(cls, sql: str) -> List[str]:
|
|
378
401
|
"""
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
- In Safe Mode: Enforces strict Read-Only operations, blocking all DML, DDL, DO blocks, CALL, MERGE, SET, and state-modifying functions.
|
|
383
|
-
- 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.
|
|
384
405
|
"""
|
|
385
|
-
#
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
392
425
|
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
395
464
|
return False, ""
|
|
396
465
|
|
|
397
|
-
q_upper =
|
|
466
|
+
q_upper = q_norm.upper()
|
|
398
467
|
|
|
399
468
|
# SAFE MODE: STRICT READ-ONLY ENFORCEMENT
|
|
400
469
|
if is_safe_mode:
|
|
@@ -415,11 +484,11 @@ class DatabaseEngine:
|
|
|
415
484
|
(r"\bDO\b", "DO procedural block"),
|
|
416
485
|
(r"\bCALL\b", "CALL stored procedure"),
|
|
417
486
|
(r"\bMERGE\b", "MERGE statement"),
|
|
418
|
-
(r"\bSET_CONFIG\
|
|
419
|
-
(r"\bSETVAL\
|
|
420
|
-
(r"\bNEXTVAL\
|
|
421
|
-
(r"\bCURRVAL\
|
|
422
|
-
(r"\bLASTVAL\
|
|
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"),
|
|
423
492
|
(r"\bPG_TERMINATE_BACKEND\b", "PG_TERMINATE_BACKEND admin call"),
|
|
424
493
|
(r"\bPG_CANCEL_BACKEND\b", "PG_CANCEL_BACKEND admin call"),
|
|
425
494
|
(r"\bLO_IMPORT\b", "LO_IMPORT filesystem read"),
|
|
@@ -437,7 +506,7 @@ class DatabaseEngine:
|
|
|
437
506
|
|
|
438
507
|
return False, ""
|
|
439
508
|
|
|
440
|
-
# NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops)
|
|
509
|
+
# NORMAL MODE: Destructive Guard (Unconstrained modifications & schema drops per statement)
|
|
441
510
|
if re.search(r"\bDROP\s+(TABLE|DATABASE|SCHEMA|VIEW)\b", q_upper):
|
|
442
511
|
return True, "DROP operation detected"
|
|
443
512
|
if re.search(r"\bTRUNCATE\b", q_upper):
|
|
@@ -451,21 +520,56 @@ class DatabaseEngine:
|
|
|
451
520
|
|
|
452
521
|
return False, ""
|
|
453
522
|
|
|
454
|
-
def
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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, ""
|
|
532
|
+
|
|
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
|
|
461
537
|
|
|
462
|
-
|
|
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:
|
|
463
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
|
+
}
|
|
464
568
|
|
|
465
569
|
if self.db_type == "sqlite":
|
|
466
|
-
explain_sql = f"EXPLAIN QUERY PLAN {
|
|
570
|
+
explain_sql = f"EXPLAIN QUERY PLAN {single_q};"
|
|
467
571
|
else:
|
|
468
|
-
explain_sql = f"EXPLAIN {
|
|
572
|
+
explain_sql = f"EXPLAIN {single_q};"
|
|
469
573
|
return self.execute_query(explain_sql)
|
|
470
574
|
|
|
471
575
|
@staticmethod
|
|
@@ -528,6 +632,33 @@ class DatabaseEngine:
|
|
|
528
632
|
result_parts.append(sql_text.strip())
|
|
529
633
|
return "\n".join(result_parts)
|
|
530
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
|
+
|
|
531
662
|
# --- Query execution ---
|
|
532
663
|
|
|
533
664
|
def execute_query(self, query: str, max_rows: int = 1000) -> Dict[str, Any]:
|
|
@@ -584,7 +715,7 @@ class DatabaseEngine:
|
|
|
584
715
|
# MySQL optimization: Auto-apply LIMIT on SELECTs without LIMIT to prevent server unbuffered socket drain
|
|
585
716
|
exec_query = query
|
|
586
717
|
if self.db_type == "mysql" and not is_write_dml:
|
|
587
|
-
if
|
|
718
|
+
if self.is_mysql_select_needing_limit(query):
|
|
588
719
|
exec_query = query.rstrip().rstrip(";") + f" LIMIT {max_rows + 1};"
|
|
589
720
|
|
|
590
721
|
cur = self.conn.cursor()
|
|
@@ -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" }]
|
|
@@ -302,9 +302,145 @@ def test_result_set_memory_limit():
|
|
|
302
302
|
print(" [+] Result Set Memory Limit: PASS")
|
|
303
303
|
|
|
304
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
|
+
|
|
305
441
|
def main():
|
|
306
442
|
print("\n" + "=" * 60)
|
|
307
|
-
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.
|
|
443
|
+
print("RUNNING EXHAUSTIVE REFINED VERIFICATION TESTS (v0.1.5)")
|
|
308
444
|
print("=" * 60)
|
|
309
445
|
|
|
310
446
|
test_safe_mode_and_comment_bypass()
|
|
@@ -316,6 +452,11 @@ def main():
|
|
|
316
452
|
test_exporter_json_types_and_dialect_sql()
|
|
317
453
|
test_table_quoting_and_cte_export_detection()
|
|
318
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()
|
|
319
460
|
|
|
320
461
|
print("\n" + "=" * 60)
|
|
321
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
|