security(auth): write initial admin password to 0600 file instead of logs

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.
This commit is contained in:
greatmengqi 2026-04-08 12:58:42 +08:00
parent ea73db6fc1
commit e7a881b577
3 changed files with 113 additions and 47 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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__":