Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
**Vulnerability:** User-provided string fields (like project and connection names) lacked strict validation against control characters, only relying on length constraints.
**Learning:** This could potentially lead to Log Injection (CRLF injection), Null Byte Injection, or terminal escape injection if these strings are subsequently logged or rendered directly.
**Prevention:** Use explicit regex validation `pattern=r'^[^\x00-\x1F\x7F]+$'` on Pydantic string fields to strictly reject control characters.
## 2025-02-18 - Silencing Bandit False Positive SQL Injection Warnings (B608)
**Vulnerability:** Bandit B608 reports dynamic string formatting (like `f"FROM information_schema.TABLES WHERE {where} "`) as potential SQL injection vulnerabilities.
**Learning:** If the dynamic parameter is constructed entirely via internal logic (e.g., hardcoded constants like `"TABLE_SCHEMA = %s"`) rather than untrusted user input, it is completely safe.
**Prevention:** Append `# nosec B608` to the specific line performing the safe string concatenation to silence the false positive while ensuring actual parameterized data injection is correctly handled by the database cursor.
8 changes: 4 additions & 4 deletions backend/app/mysql_introspect/introspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,15 +275,15 @@ def _introspect_sync(config: MysqlDsnConfig, schema_filter: str | None) -> dict[
tables = _fetch_dicts(
cursor,
"SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, TABLE_COMMENT "
f"FROM information_schema.TABLES WHERE {where} "
f"FROM information_schema.TABLES WHERE {where} " # nosec B608
"ORDER BY TABLE_SCHEMA, TABLE_NAME",
params,
)
columns = _fetch_dicts(
cursor,
"SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, "
"COLUMN_TYPE, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT "
f"FROM information_schema.COLUMNS WHERE {where} "
f"FROM information_schema.COLUMNS WHERE {where} " # nosec B608
"ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION",
params,
)
Expand All @@ -292,15 +292,15 @@ def _introspect_sync(config: MysqlDsnConfig, schema_filter: str | None) -> dict[
"SELECT CONSTRAINT_NAME, TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, "
"ORDINAL_POSITION, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, "
"REFERENCED_COLUMN_NAME "
f"FROM information_schema.KEY_COLUMN_USAGE WHERE {where} "
f"FROM information_schema.KEY_COLUMN_USAGE WHERE {where} " # nosec B608
"ORDER BY TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION",
params,
)
indexes = _fetch_dicts(
cursor,
"SELECT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, NON_UNIQUE, "
"SEQ_IN_INDEX, COLUMN_NAME "
f"FROM information_schema.STATISTICS WHERE {where} "
f"FROM information_schema.STATISTICS WHERE {where} " # nosec B608
"ORDER BY TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX",
params,
)
Expand Down
59 changes: 58 additions & 1 deletion backend/tests/test_mysql_introspect.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from __future__ import annotations

import inspect
from unittest.mock import AsyncMock, patch

import pytest

from app.db_introspect import detect_dsn_dialect
from app.ddl.export import snapshot_json_to_sql
from app.mysql_introspect.introspect import _parse_mysql_dsn, rows_to_snapshot
from app.mysql_introspect import introspect as mysql_introspect
from app.mysql_introspect.introspect import _introspect_sync, _parse_mysql_dsn, rows_to_snapshot

TABLES = [
{"TABLE_SCHEMA": "shop", "TABLE_NAME": "member", "TABLE_TYPE": "BASE TABLE", "TABLE_COMMENT": "회원"},
Expand Down Expand Up @@ -81,6 +83,61 @@ def test_snapshot_feeds_ddl_export_and_dialect_detection():
assert detect_dsn_dialect("mariadb://u:p@db.example.com/shop") == "mysql"


class _RecordingCursor:
def __init__(self) -> None:
self.calls: list[tuple[str, tuple[object, ...] | None]] = []
self.description: list[tuple[str]] = []
self._rows: list[tuple[object, ...]] = []

def execute(self, sql: str, params: tuple[object, ...] | None) -> None:
self.calls.append((sql, params))
if "SELECT VERSION()" in sql:
self.description = [("v",)]
self._rows = [("8.4.0",)]
else:
self.description = []
self._rows = []

def fetchall(self) -> list[tuple[object, ...]]:
return self._rows


class _RecordingConnection:
def __init__(self) -> None:
self.recording_cursor = _RecordingCursor()
self.closed = False

def cursor(self) -> _RecordingCursor:
return self.recording_cursor

def close(self) -> None:
self.closed = True


def test_introspection_sql_keeps_hostile_schema_filter_in_parameters_without_suppression() -> None:
hostile_schema = "tenant' OR 1=1 --\n"
connection = _RecordingConnection()
config = mysql_introspect.MysqlDsnConfig(
host="203.0.113.10",
server_hostname="db.example.com",
port=3306,
user="reader",
password="secret",
database=None,
)

with patch("app.mysql_introspect.introspect._connect", return_value=connection):
_introspect_sync(config, hostile_schema)

metadata_calls = connection.recording_cursor.calls[1:]
assert len(metadata_calls) == 4
for sql, params in metadata_calls:
assert hostile_schema not in sql
assert params is not None and hostile_schema in params
assert connection.closed is True
assert "# nosec B608" in inspect.getsource(mysql_introspect)


@pytest.mark.asyncio
async def test_dsn_parse_pins_validated_ip_and_rejects_bad():
with patch(
Expand Down
Loading
Loading