feat(config): wrap credentials in SecretStr (audit S-4)
database_url, migration_database_url, mcp_auth_token and the mathpix app id/key are now pydantic SecretStr, so they render as '**********' in any repr/log/traceback instead of leaking the plaintext credential. Consumers (db.get_conn, cli.migrate, mcp._require_http_token, mathpix.extract_formulas) call .get_secret_value() at the point of use. Tests updated to the SecretStr type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,7 @@ def migrate() -> None:
|
|||||||
from codex.db import apply_schema
|
from codex.db import apply_schema
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
url = settings.migration_database_url or settings.database_url
|
url = (settings.migration_database_url or settings.database_url).get_secret_value()
|
||||||
try:
|
try:
|
||||||
with psycopg.connect(url, row_factory=psycopg.rows.dict_row) as conn:
|
with psycopg.connect(url, row_factory=psycopg.rows.dict_row) as conn:
|
||||||
apply_schema(conn)
|
apply_schema(conn)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field, SecretStr
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -26,15 +26,15 @@ class Settings(BaseSettings):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Database
|
# Database
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
database_url: str = Field(
|
database_url: SecretStr = Field(
|
||||||
default="postgresql://researcher:change_me@localhost:5432/papers",
|
default=SecretStr("postgresql://researcher:change_me@localhost:5432/papers"),
|
||||||
description=(
|
description=(
|
||||||
"libpq-compatible connection string consumed by psycopg. "
|
"libpq-compatible connection string consumed by psycopg. "
|
||||||
"Example: postgresql://user:pass@host:5432/dbname"
|
"Example: postgresql://user:pass@host:5432/dbname"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
migration_database_url: str | None = Field(
|
migration_database_url: SecretStr | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"Optional privileged connection string used by `codex migrate` to apply "
|
"Optional privileged connection string used by `codex migrate` to apply "
|
||||||
@@ -148,7 +148,7 @@ class Settings(BaseSettings):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# F-09 Rich Parsing
|
# F-09 Rich Parsing
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
mathpix_app_id: str | None = Field(
|
mathpix_app_id: SecretStr | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"MathPix App ID for cloud formula extraction. "
|
"MathPix App ID for cloud formula extraction. "
|
||||||
@@ -156,7 +156,7 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
mathpix_app_key: str | None = Field(
|
mathpix_app_key: SecretStr | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"MathPix App Key for cloud formula extraction. "
|
"MathPix App Key for cloud formula extraction. "
|
||||||
@@ -257,7 +257,7 @@ class Settings(BaseSettings):
|
|||||||
description="Bind port for HTTP transport (ignored for stdio).",
|
description="Bind port for HTTP transport (ignored for stdio).",
|
||||||
)
|
)
|
||||||
|
|
||||||
mcp_auth_token: str | None = Field(
|
mcp_auth_token: SecretStr | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
"Bearer token required when mcp_transport='http'. "
|
"Bearer token required when mcp_transport='http'. "
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def get_conn() -> Generator[psycopg.Connection[psycopg.rows.DictRow], None, None
|
|||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
with psycopg.connect(
|
with psycopg.connect(
|
||||||
settings.database_url,
|
settings.database_url.get_secret_value(),
|
||||||
row_factory=psycopg.rows.dict_row,
|
row_factory=psycopg.rows.dict_row,
|
||||||
) as conn:
|
) as conn:
|
||||||
yield conn
|
yield conn
|
||||||
|
|||||||
@@ -294,12 +294,13 @@ def _require_http_token() -> str:
|
|||||||
from codex.config import get_settings
|
from codex.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.mcp_auth_token:
|
token = settings.mcp_auth_token.get_secret_value() if settings.mcp_auth_token else ""
|
||||||
|
if not token:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"HTTP transport requires MCP_AUTH_TOKEN to be set. "
|
"HTTP transport requires MCP_AUTH_TOKEN to be set. "
|
||||||
"Set the environment variable or add it to .env."
|
"Set the environment variable or add it to .env."
|
||||||
)
|
)
|
||||||
return settings.mcp_auth_token
|
return token
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -302,8 +302,10 @@ def extract_formulas(
|
|||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
app_id = mathpix_app_id or settings.mathpix_app_id or ""
|
settings_id = settings.mathpix_app_id.get_secret_value() if settings.mathpix_app_id else ""
|
||||||
app_key = mathpix_app_key or settings.mathpix_app_key or ""
|
settings_key = settings.mathpix_app_key.get_secret_value() if settings.mathpix_app_key else ""
|
||||||
|
app_id = mathpix_app_id or settings_id
|
||||||
|
app_key = mathpix_app_key or settings_key
|
||||||
|
|
||||||
if app_id and app_key:
|
if app_id and app_key:
|
||||||
logger.info("Using MathPix backend for %s", pdf_path)
|
logger.info("Using MathPix backend for %s", pdf_path)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _require_http_token
|
# _require_http_token
|
||||||
@@ -37,7 +38,7 @@ def test_require_http_token_returns_token_when_set() -> None:
|
|||||||
from codex.mcp_server import _require_http_token
|
from codex.mcp_server import _require_http_token
|
||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.mcp_auth_token = "super-secret-token"
|
mock_settings.mcp_auth_token = SecretStr("super-secret-token")
|
||||||
|
|
||||||
with patch("codex.config.get_settings", return_value=mock_settings):
|
with patch("codex.config.get_settings", return_value=mock_settings):
|
||||||
result = _require_http_token()
|
result = _require_http_token()
|
||||||
@@ -97,7 +98,7 @@ def test_main_http_with_token_calls_uvicorn() -> None:
|
|||||||
|
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.mcp_transport = "http"
|
mock_settings.mcp_transport = "http"
|
||||||
mock_settings.mcp_auth_token = "test-token"
|
mock_settings.mcp_auth_token = SecretStr("test-token")
|
||||||
mock_settings.mcp_host = "127.0.0.1"
|
mock_settings.mcp_host = "127.0.0.1"
|
||||||
mock_settings.mcp_port = 8765
|
mock_settings.mcp_port = 8765
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Any
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from codex.models import FormulaChunk
|
from codex.models import FormulaChunk
|
||||||
|
|
||||||
@@ -295,8 +296,8 @@ class TestRouteSelection:
|
|||||||
def test_mathpix_selected_when_creds_present(self) -> None:
|
def test_mathpix_selected_when_creds_present(self) -> None:
|
||||||
"""extract_formulas calls MathPix backend when both creds are set."""
|
"""extract_formulas calls MathPix backend when both creds are set."""
|
||||||
mock_settings = MagicMock()
|
mock_settings = MagicMock()
|
||||||
mock_settings.mathpix_app_id = "my_id"
|
mock_settings.mathpix_app_id = SecretStr("my_id")
|
||||||
mock_settings.mathpix_app_key = "my_key"
|
mock_settings.mathpix_app_key = SecretStr("my_key")
|
||||||
mock_settings.pix2tex_fallback = True
|
mock_settings.pix2tex_fallback = True
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ from codex.config import Settings, get_settings
|
|||||||
def test_settings_defaults() -> None:
|
def test_settings_defaults() -> None:
|
||||||
"""Settings() is constructable without env vars; expected defaults are set."""
|
"""Settings() is constructable without env vars; expected defaults are set."""
|
||||||
s = Settings()
|
s = Settings()
|
||||||
assert s.database_url == "postgresql://researcher:change_me@localhost:5432/papers"
|
assert (
|
||||||
|
s.database_url.get_secret_value()
|
||||||
|
== "postgresql://researcher:change_me@localhost:5432/papers"
|
||||||
|
)
|
||||||
assert s.embedding_model == "BAAI/bge-m3"
|
assert s.embedding_model == "BAAI/bge-m3"
|
||||||
assert s.embedding_dim == 1024
|
assert s.embedding_dim == 1024
|
||||||
|
|
||||||
@@ -20,7 +23,7 @@ def test_settings_env_override(monkeypatch: object) -> None:
|
|||||||
mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment]
|
mp: pytest.MonkeyPatch = monkeypatch # type: ignore[assignment]
|
||||||
mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db")
|
mp.setenv("DATABASE_URL", "postgresql://x:y@h:5432/db")
|
||||||
s = Settings()
|
s = Settings()
|
||||||
assert s.database_url == "postgresql://x:y@h:5432/db"
|
assert s.database_url.get_secret_value() == "postgresql://x:y@h:5432/db"
|
||||||
|
|
||||||
|
|
||||||
def test_get_settings_is_singleton() -> None:
|
def test_get_settings_is_singleton() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user