From e7a881b57725bd77d0d3cbe61186977598a5ff28 Mon Sep 17 00:00:00 2001 From: greatmengqi Date: Wed, 8 Apr 2026 12:58:42 +0800 Subject: [PATCH] security(auth): write initial admin password to 0600 file instead of logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL py/clear-text-logging-sensitive-data flagged 3 call sites that logged the auto-generated admin password to stdout via logger.info(). Production log aggregators (ELK/Splunk/etc) would have captured those cleartext secrets. Replace with a shared helper that writes to .deer-flow/admin_initial_credentials.txt with mode 0600, and log only the path. New file -------- - app/gateway/auth/credential_file.py: write_initial_credentials() helper. Takes email, password, and a "initial"/"reset" label. Creates .deer-flow/ if missing, writes a header comment plus the email+password, chmods 0o600, returns the absolute Path. Modified -------- - app/gateway/app.py: both _ensure_admin_user paths (fresh creation + needs_setup password reset) now write to file and log the path - app/gateway/auth/reset_admin.py: rewritten to use the shared ORM repo (SQLiteUserRepository with session_factory) and the credential_file helper. The previous implementation was broken after the earlier ORM refactor — it still imported _get_users_conn and constructed SQLiteUserRepository() without a session factory. No tests changed — the three password-log sites are all exercised via existing test_ensure_admin.py which checks that startup succeeds, not that a specific string appears in logs. CodeQL alerts 272, 283, 284: all resolved. --- backend/app/gateway/app.py | 11 +- backend/app/gateway/auth/credential_file.py | 38 +++++++ backend/app/gateway/auth/reset_admin.py | 111 ++++++++++++-------- 3 files changed, 113 insertions(+), 47 deletions(-) create mode 100644 backend/app/gateway/auth/credential_file.py diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 0967625e6..a85fe7a61 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -87,6 +87,7 @@ async def _ensure_admin_user(app: FastAPI) -> None: age = time.time() - admin.created_at.replace(tzinfo=UTC).timestamp() if age >= 30: + from app.gateway.auth.credential_file import write_initial_credentials from app.gateway.auth.password import hash_password_async password = secrets.token_urlsafe(16) @@ -94,10 +95,10 @@ async def _ensure_admin_user(app: FastAPI) -> None: admin.token_version += 1 await provider.update_user(admin) + cred_path = write_initial_credentials(admin.email, password, label="reset") logger.info("=" * 60) logger.info(" Admin account setup incomplete — password reset") - logger.info(" Email: %s", admin.email) - logger.info(" Password: %s", password) + logger.info(" Credentials written to: %s (mode 0600)", cred_path) logger.info(" Change it after login: Settings -> Account") logger.info("=" * 60) @@ -119,10 +120,12 @@ async def _ensure_admin_user(app: FastAPI) -> None: logger.exception("LangGraph thread migration failed (non-fatal)") if fresh_admin_created: + from app.gateway.auth.credential_file import write_initial_credentials + + cred_path = write_initial_credentials(admin.email, password, label="initial") # noqa: F821 — defined in the fresh_admin branch logger.info("=" * 60) logger.info(" Admin account created on first boot") - logger.info(" Email: %s", admin.email) - logger.info(" Password: %s", password) # noqa: F821 — defined in the fresh_admin branch + logger.info(" Credentials written to: %s (mode 0600)", cred_path) logger.info(" Change it after login: Settings -> Account") logger.info("=" * 60) diff --git a/backend/app/gateway/auth/credential_file.py b/backend/app/gateway/auth/credential_file.py new file mode 100644 index 000000000..5aa52ffe0 --- /dev/null +++ b/backend/app/gateway/auth/credential_file.py @@ -0,0 +1,38 @@ +"""Write initial admin credentials to a restricted file instead of logs. + +Logging secrets to stdout/stderr is a well-known CodeQL finding +(py/clear-text-logging-sensitive-data) — in production those logs +get collected into ELK/Splunk/etc and become a secret sprawl +source. This helper writes the credential to a 0600 file that only +the process user can read, and returns the path so the caller can +log **the path** (not the password) for the operator to pick up. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_CREDENTIAL_FILE = Path(".deer-flow") / "admin_initial_credentials.txt" + + +def write_initial_credentials(email: str, password: str, *, label: str = "initial") -> Path: + """Write the admin email + password to ``.deer-flow/admin_initial_credentials.txt``. + + Creates the parent directory if it does not exist. Sets the file + mode to 0600 so only the owning process user can read it. + + ``label`` distinguishes "initial" (fresh creation) from "reset" + (password reset) in the file header, so an operator picking up + the file after a restart can tell which event produced it. + + Returns the absolute :class:`Path` to the file. + """ + _CREDENTIAL_FILE.parent.mkdir(parents=True, exist_ok=True) + + content = ( + f"# DeerFlow admin {label} credentials\n# This file is generated on first boot or password reset.\n# Change the password after login via Settings -> Account,\n# then delete this file.\n#\nemail: {email}\npassword: {password}\n" + ) + _CREDENTIAL_FILE.write_text(content) + os.chmod(_CREDENTIAL_FILE, 0o600) + return _CREDENTIAL_FILE.resolve() diff --git a/backend/app/gateway/auth/reset_admin.py b/backend/app/gateway/auth/reset_admin.py index 0ac2e0701..7b7da74d0 100644 --- a/backend/app/gateway/auth/reset_admin.py +++ b/backend/app/gateway/auth/reset_admin.py @@ -1,16 +1,81 @@ -"""CLI tool to reset admin password. +"""CLI tool to reset an admin password. Usage: python -m app.gateway.auth.reset_admin python -m app.gateway.auth.reset_admin --email admin@example.com + +Writes the new password to ``.deer-flow/admin_initial_credentials.txt`` +(mode 0600) instead of printing it, so CI / log aggregators never see +the cleartext secret. """ +from __future__ import annotations + import argparse +import asyncio import secrets import sys +from sqlalchemy import select + +from app.gateway.auth.credential_file import write_initial_credentials from app.gateway.auth.password import hash_password from app.gateway.auth.repositories.sqlite import SQLiteUserRepository +from deerflow.persistence.user.model import UserRow + + +async def _run(email: str | None) -> int: + from deerflow.config import get_app_config + from deerflow.persistence.engine import ( + close_engine, + get_session_factory, + init_engine_from_config, + ) + + config = get_app_config() + await init_engine_from_config(config.database) + try: + sf = get_session_factory() + if sf is None: + print("Error: persistence engine not available (check config.database).", file=sys.stderr) + return 1 + + repo = SQLiteUserRepository(sf) + + if email: + user = await repo.get_user_by_email(email) + else: + # Find first admin via direct SELECT — repository does not + # expose a "first admin" helper and we do not want to add + # one just for this CLI. + async with sf() as session: + stmt = select(UserRow).where(UserRow.system_role == "admin").limit(1) + row = (await session.execute(stmt)).scalar_one_or_none() + if row is None: + user = None + else: + user = await repo.get_user_by_id(row.id) + + if user is None: + if email: + print(f"Error: user '{email}' not found.", file=sys.stderr) + else: + print("Error: no admin user found.", file=sys.stderr) + return 1 + + new_password = secrets.token_urlsafe(16) + user.password_hash = hash_password(new_password) + user.token_version += 1 + user.needs_setup = True + await repo.update_user(user) + + cred_path = write_initial_credentials(user.email, new_password, label="reset") + print(f"Password reset for: {user.email}") + print(f"Credentials written to: {cred_path} (mode 0600)") + print("Next login will require setup (new email + password).") + return 0 + finally: + await close_engine() def main() -> None: @@ -18,48 +83,8 @@ def main() -> None: parser.add_argument("--email", help="Admin email (default: first admin found)") args = parser.parse_args() - repo = SQLiteUserRepository() - - # Find admin user synchronously (CLI context, no event loop) - import asyncio - - user = asyncio.run(_find_admin(repo, args.email)) - if user is None: - if args.email: - print(f"Error: user '{args.email}' not found.", file=sys.stderr) - else: - print("Error: no admin user found.", file=sys.stderr) - sys.exit(1) - - new_password = secrets.token_urlsafe(16) - user.password_hash = hash_password(new_password) - user.token_version += 1 - user.needs_setup = True - asyncio.run(repo.update_user(user)) - - print(f"Password reset for: {user.email}") - print(f"New password: {new_password}") - print("Next login will require setup (new email + password).") - - -async def _find_admin(repo: SQLiteUserRepository, email: str | None): - if email: - return await repo.get_user_by_email(email) - # Find first admin - import asyncio - - from app.gateway.auth.repositories.sqlite import _get_users_conn - - def _find_sync(): - with _get_users_conn() as conn: - cursor = conn.execute("SELECT id FROM users WHERE system_role = 'admin' LIMIT 1") - row = cursor.fetchone() - return dict(row)["id"] if row else None - - admin_id = await asyncio.to_thread(_find_sync) - if admin_id: - return await repo.get_user_by_id(admin_id) - return None + exit_code = asyncio.run(_run(args.email)) + sys.exit(exit_code) if __name__ == "__main__":