Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions tests/test_componentes.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,64 @@ def test_sql_rejeita_vazia():
assert ok is False


# Variantes de "SELECT ... INTO OUTFILE/DUMPFILE" (escrita de arquivo no servidor
# MySQL). Todas começam com SELECT, então só o blacklist as barra. O MySQL aceita
# comentários entre INTO e OUTFILE, então espaço não pode ser o único separador.
VARIANTES_ESCRITA_DE_ARQUIVO = [
"SELECT * FROM t INTO OUTFILE '/tmp/x'",
"select * from t into outfile '/tmp/x'",
"SELECT * FROM t INTO DUMPFILE '/tmp/x'",
"SELECT * FROM t INTO/**/OUTFILE '/tmp/x'",
"SELECT * FROM t INTO/*qualquer coisa*/DUMPFILE '/tmp/x'",
"SELECT * FROM t INTO /*!50000 OUTFILE*/ '/tmp/x'",
"SELECT * FROM t INTO -- x\nOUTFILE '/tmp/x'",
"SELECT * FROM t INTO #x\nOUTFILE '/tmp/x'",
"SELECT * FROM t\nINTO\nOUTFILE '/tmp/x'",
]


@pytest.mark.parametrize("sql", VARIANTES_ESCRITA_DE_ARQUIVO)
def test_sql_rejeita_escrita_de_arquivo_mysql(sql):
"""INTO OUTFILE/DUMPFILE é bloqueado qualquer que seja o separador entre as palavras."""
from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura

ok, msg = validar_sql_segura(sql)
assert ok is False, f"deveria bloquear: {sql!r}"


@pytest.mark.parametrize(
"sql",
[
"SELECT LOAD_FILE('/etc/passwd')",
"SELECT pg_read_file('/etc/passwd')",
"SELECT load_extension('/tmp/x.so')",
],
)
def test_sql_rejeita_leitura_de_arquivo_por_funcao(sql):
"""Funções que leem arquivo do servidor também são bloqueadas."""
from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura

ok, msg = validar_sql_segura(sql)
assert ok is False, f"deveria bloquear: {sql!r}"


@pytest.mark.parametrize(
"sql",
[
"SELECT * FROM orders",
"SELECT nome INTO @variavel FROM clientes LIMIT 1", # INTO @var não escreve arquivo
"SELECT COUNT(*) FROM orders /* comentario inocente */",
"WITH t AS (SELECT 1) SELECT * FROM t",
],
)
def test_sql_legitima_continua_permitida(sql):
"""O blacklist não pode gerar falso positivo em consultas normais."""
from text_to_insight.nodes.code_agent.code_sql import validar_sql_segura

ok, msg = validar_sql_segura(sql)
assert ok is True, f"deveria permitir: {sql!r} ({msg})"


# ============================================================
# SQL Execution (code_sql.py)
# ============================================================
Expand Down
44 changes: 44 additions & 0 deletions tests/test_data_exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,50 @@ def test_exploration_with_colunas_para_explorar(self, sample_db):
assert "name" not in text
assert "category" not in text

# --- Bancos que não são um arquivo SQLite local: o nó deve PULAR ---------
# Cenário real: o usuário roda com --db-dialeto postgresql (ou --db-url) mas
# --db-path sobra com um .db antigo que EXISTE. Sem o guard, o nó exploraria
# esse arquivo sem relação com o banco de verdade.

@pytest.mark.parametrize(
"campos_do_banco",
[
{"db_dialeto": "postgresql"},
{"db_dialeto": "mysql"},
{"db_url": "postgresql://u:p@localhost:5432/banco"},
{"db_url": "sqlite:///outro_arquivo.db"},
],
)
def test_skips_when_backend_is_not_local_sqlite(self, sample_db, campos_do_banco):
# sample_db é um .db REAL e explorável: se o guard falhasse, o nó o exploraria.
estado = {
"db_path": sample_db,
"contexto_rag_schema": "Tabela: products\n- id: INTEGER\n",
**campos_do_banco,
}
result = nos_nodo_data_exploration(estado)
assert result == {}

def test_skips_for_duckdb_extension(self, sample_db, tmp_path):
# .duckdb é detectado pela extensão do db_path; o nó não sabe abrir DuckDB.
arquivo = tmp_path / "dados.duckdb"
arquivo.write_bytes(Path(sample_db).read_bytes())
estado = {
"db_path": str(arquivo),
"contexto_rag_schema": "Tabela: products\n- id: INTEGER\n",
}
assert nos_nodo_data_exploration(estado) == {}

def test_still_explores_when_dialect_is_explicit_sqlite(self, sample_db):
estado = {
"db_path": sample_db,
"db_dialeto": "sqlite",
"db_url": "", # vazio/None não conta como db_url preenchido
"contexto_rag_schema": "Tabela: products\n- id: INTEGER\n",
}
result = nos_nodo_data_exploration(estado)
assert "products" in result["contexto_data_exploration"]


class MockLLMResponse:
def __init__(self, content):
Expand Down
10 changes: 9 additions & 1 deletion text_to_insight/nodes/code_agent/code_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
# mas na prática escrevem ou leem arquivos no servidor de banco:
# - MySQL: "SELECT ... INTO OUTFILE/DUMPFILE" escreve um arquivo;
# LOAD_FILE() lê um arquivo arbitrário do disco.
# As palavras OUTFILE e DUMPFILE são bloqueadas SOZINHAS (e não a
# frase "INTO OUTFILE"): o MySQL aceita comentários entre as duas
# palavras (INTO/**/OUTFILE, INTO /*!50000 OUTFILE*/, ou um
# comentário de linha seguido de quebra de linha), então casar a
# frase com \s+ deixava essas variantes passarem. Como OUTFILE e
# DUMPFILE só existem nessa construção, bloquear a palavra isolada
# não depende de qual separador foi usado. (Custo: uma coluna
# chamada "outfile" também seria bloqueada — aceitável.)
# - PostgreSQL: pg_read_file/pg_read_binary_file/pg_ls_dir leem arquivos e
# diretórios do servidor; lo_import/lo_export leem/escrevem via
# large objects — todas exigem privilégio elevado, mas são
Expand All @@ -28,7 +36,7 @@
r"\b("
r"insert|update|delete|drop|alter|truncate|create|attach|detach|pragma|"
r"vacuum|reindex|replace|"
r"into\s+outfile|into\s+dumpfile|load_file|load_extension|"
r"outfile|dumpfile|load_file|load_extension|"
r"pg_read_file|pg_read_binary_file|pg_ls_dir|lo_import|lo_export"
r")\b",
re.IGNORECASE,
Expand Down
30 changes: 30 additions & 0 deletions text_to_insight/nodes/data_exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,28 @@ def _extract_table_names_from_rag_context(contexto_rag: str) -> list[str]:
# Nó do grafo LangGraph
# ---------------------------------------------------------------------------

def _backend_suporta_exploracao(estado: EstadoTextToInsight, db_path: str) -> bool:
"""
Este nó só sabe explorar um arquivo SQLite local (abre `db_path` com
sqlite3). Para qualquer outro banco ele não consegue explorar — e, pior,
se `db_path` sobrou preenchido com um valor antigo (o default do CLI
aponta para um .db do projeto), ele exploraria esse arquivo sem relação
nenhuma com o banco de verdade, devolvendo estatísticas erradas pra IA.

Retorna False quando:
- `db_url` está preenchido (o banco real vem da URL, não do db_path);
- ou o dialeto efetivo não é sqlite (`db_dialeto` explícito, ou deduzido
pela extensão do db_path, ex: .duckdb).
"""
if (estado.get("db_url") or "").strip():
return False

from .schema import _detectar_dialeto

dialeto = (estado.get("db_dialeto") or "").strip().lower() or _detectar_dialeto(db_path)
return dialeto == "sqlite"


def nos_nodo_data_exploration(
estado: EstadoTextToInsight,
use_data_exploration: bool = True,
Expand All @@ -501,6 +523,14 @@ def nos_nodo_data_exploration(
print("[DATA_EXPLORATION] Sem contexto RAG ou db_path — pulando exploração.")
return {}

if not _backend_suporta_exploracao(estado, db_path):
print(
"[DATA_EXPLORATION] O banco configurado não é um arquivo SQLite local "
"(db_dialeto/db_url/extensão do db_path) — exploração ainda não "
"suportada para esse banco, pulando."
)
return {}

# Extrair nomes de tabelas do contexto do retriever
table_names = _extract_table_names_from_rag_context(contexto_rag)
if not table_names:
Expand Down
Loading