diff --git a/tests/test_componentes.py b/tests/test_componentes.py index 2e95a40..bb96aa7 100644 --- a/tests/test_componentes.py +++ b/tests/test_componentes.py @@ -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) # ============================================================ diff --git a/tests/test_data_exploration.py b/tests/test_data_exploration.py index c7e0458..b844b70 100644 --- a/tests/test_data_exploration.py +++ b/tests/test_data_exploration.py @@ -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): diff --git a/text_to_insight/nodes/code_agent/code_sql.py b/text_to_insight/nodes/code_agent/code_sql.py index ae1ccc3..fa884ae 100644 --- a/text_to_insight/nodes/code_agent/code_sql.py +++ b/text_to_insight/nodes/code_agent/code_sql.py @@ -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 @@ -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, diff --git a/text_to_insight/nodes/data_exploration.py b/text_to_insight/nodes/data_exploration.py index 976853f..7a73f0f 100644 --- a/text_to_insight/nodes/data_exploration.py +++ b/text_to_insight/nodes/data_exploration.py @@ -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, @@ -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: