security: rotate bootstrap and clean workspace

This commit is contained in:
2026-03-13 12:14:37 +03:00
parent 03a90f58bd
commit feb07bf366
10 changed files with 219 additions and 21 deletions

16
.gitignore vendored
View File

@@ -65,13 +65,15 @@ backend/mappings.db
backend/tasks.db
backend/logs
backend/auth.db
semantics/reports
backend/tasks.db
# Universal / tooling
node_modules/
backend/logs
backend/auth.db
semantics/reports
backend/tasks.db
backend/**/*.db
backend/**/*.sqlite
# Universal / tooling
node_modules/
.venv/
coverage/
*.tmp

View File

@@ -151,8 +151,10 @@ cd backend
source .venv/bin/activate
python src/scripts/init_auth_db.py
# При первом запуске будет создан backend/.env с ENCRYPTION_KEY
# Создание администратора
python src/scripts/create_admin.py --username admin --password admin
python src/scripts/create_admin.py --username admin --password '<strong-temporary-secret>'
```
## 🏢 Enterprise Clean Deployment (internal-only)

View File

@@ -8,8 +8,10 @@
# @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS]
import os
import sys
from pathlib import Path
from cryptography.fernet import Fernet
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
@@ -19,6 +21,41 @@ from src.core.logger import logger, belief_scope
from src.scripts.seed_permissions import seed_permissions
# [/SECTION]
ENV_FILE_PATH = Path(__file__).resolve().parents[2] / ".env"
# [DEF:ensure_encryption_key:Function]
# @PURPOSE: Ensure backend runtime has a persistent Fernet encryption key during first-time installation.
# @PRE: Backend root is writable or ENCRYPTION_KEY is already provided via environment.
# @POST: ENCRYPTION_KEY exists in process environment or backend/.env.
def ensure_encryption_key(env_file_path: Path = ENV_FILE_PATH) -> str:
existing_key = os.getenv("ENCRYPTION_KEY", "").strip()
if existing_key:
Fernet(existing_key.encode())
logger.info("ENCRYPTION_KEY already provided via environment; skipping generation.")
return existing_key
if env_file_path.exists():
for raw_line in env_file_path.read_text(encoding="utf-8").splitlines():
if raw_line.startswith("ENCRYPTION_KEY="):
persisted_key = raw_line.partition("=")[2].strip()
if persisted_key:
Fernet(persisted_key.encode())
os.environ["ENCRYPTION_KEY"] = persisted_key
logger.info(f"Loaded existing ENCRYPTION_KEY from {env_file_path}.")
return persisted_key
generated_key = Fernet.generate_key().decode()
with env_file_path.open("a", encoding="utf-8") as env_file:
if env_file.tell() > 0:
env_file.write("\n")
env_file.write(f"ENCRYPTION_KEY={generated_key}\n")
os.environ["ENCRYPTION_KEY"] = generated_key
logger.info(f"Generated ENCRYPTION_KEY and persisted it to {env_file_path}.")
return generated_key
# [/DEF:ensure_encryption_key:Function]
# [DEF:run_init:Function]
# @PURPOSE: Main entry point for the initialization script.
# @POST: auth.db is initialized with the correct schema and seeded permissions.
@@ -26,6 +63,7 @@ def run_init():
with belief_scope("init_auth_db"):
logger.info("Initializing authentication database...")
try:
ensure_encryption_key()
init_db()
logger.info("Authentication database initialized successfully.")
@@ -40,4 +78,4 @@ def run_init():
if __name__ == "__main__":
run_init()
# [/DEF:backend.src.scripts.init_auth_db:Module]
# [/DEF:backend.src.scripts.init_auth_db:Module]

View File

@@ -26,8 +26,7 @@ class TestEncryptionManager:
"""Construct EncryptionManager directly using Fernet (avoids relative import chain)."""
# Re-implement the same logic as EncryptionManager to avoid import issues
# with the llm_provider module's relative imports
import os
key = os.getenv("ENCRYPTION_KEY", "REMOVED_HISTORICAL_SECRET_DO_NOT_USE").encode()
key = Fernet.generate_key()
fernet = Fernet(key)
class EncryptionManager:
@@ -99,6 +98,18 @@ class TestEncryptionManager:
assert decrypted == ""
# [/DEF:test_encrypt_empty_string:Function]
# [DEF:test_missing_key_fails_fast:Function]
# @PURPOSE: Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
# @PRE: ENCRYPTION_KEY is unset.
# @POST: RuntimeError raised during EncryptionManager construction.
def test_missing_key_fails_fast(self):
from src.services.llm_provider import EncryptionManager
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
EncryptionManager()
# [/DEF:test_missing_key_fails_fast:Function]
# [DEF:test_custom_key_roundtrip:Function]
# @PURPOSE: Custom Fernet key produces valid roundtrip.
# @PRE: Generated Fernet key.

View File

@@ -6,18 +6,35 @@
# @RELATION: DEPENDS_ON -> backend.src.core.database
# @RELATION: DEPENDS_ON -> backend.src.models.llm
from typing import List, Optional
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy.orm import Session
from ..models.llm import LLMProvider
from ..plugins.llm_analysis.models import LLMProviderConfig
from ..core.logger import belief_scope, logger
from cryptography.fernet import Fernet
import os
if TYPE_CHECKING:
from ..plugins.llm_analysis.models import LLMProviderConfig
# [DEF:_require_fernet_key:Function]
# @TIER: CRITICAL
# @PURPOSE: Load and validate the Fernet key used for secret encryption.
# @PRE: ENCRYPTION_KEY environment variable must be set to a valid Fernet key.
# @POST: Returns validated key bytes ready for Fernet initialization.
def _require_fernet_key() -> bytes:
raw_key = os.getenv("ENCRYPTION_KEY", "").strip()
if not raw_key:
raise RuntimeError("ENCRYPTION_KEY must be set to a valid Fernet key")
key = raw_key.encode()
Fernet(key)
return key
# [/DEF:_require_fernet_key:Function]
# [DEF:EncryptionManager:Class]
# @TIER: CRITICAL
# @PURPOSE: Handles encryption and decryption of sensitive data like API keys.
# @INVARIANT: Uses a secret key from environment or a default one (fallback only for dev).
# @INVARIANT: Uses only a validated secret key from environment.
#
# @TEST_CONTRACT: EncryptionManagerModel ->
# {
@@ -33,10 +50,10 @@ import os
class EncryptionManager:
# [DEF:EncryptionManager.__init__:Function]
# @PURPOSE: Initialize the encryption manager with a Fernet key.
# @PRE: ENCRYPTION_KEY env var must be set or use default dev key.
# @PRE: ENCRYPTION_KEY env var must be set to a valid Fernet key.
# @POST: Fernet instance ready for encryption/decryption.
def __init__(self):
self.key = os.getenv("ENCRYPTION_KEY", "REMOVED_HISTORICAL_SECRET_DO_NOT_USE").encode()
self.key = _require_fernet_key()
self.fernet = Fernet(self.key)
# [/DEF:EncryptionManager.__init__:Function]
@@ -97,7 +114,7 @@ class LLMProviderService:
# @PURPOSE: Creates a new LLM provider with encrypted API key.
# @PRE: config must contain valid provider configuration.
# @POST: New provider created and persisted to database.
def create_provider(self, config: LLMProviderConfig) -> LLMProvider:
def create_provider(self, config: "LLMProviderConfig") -> LLMProvider:
with belief_scope("create_provider"):
encrypted_key = self.encryption.encrypt(config.api_key)
db_provider = LLMProvider(
@@ -119,7 +136,7 @@ class LLMProviderService:
# @PURPOSE: Updates an existing LLM provider.
# @PRE: provider_id must exist, config must be valid.
# @POST: Provider updated and persisted to database.
def update_provider(self, provider_id: str, config: LLMProviderConfig) -> Optional[LLMProvider]:
def update_provider(self, provider_id: str, config: "LLMProviderConfig") -> Optional[LLMProvider]:
with belief_scope("update_provider"):
db_provider = self.get_provider(provider_id)
if not db_provider:
@@ -180,4 +197,4 @@ class LLMProviderService:
# [/DEF:LLMProviderService:Class]
# [/DEF:backend.src.services.llm_provider:Module]
# [/DEF:backend.src.services.llm_provider:Module]

View File

@@ -7,12 +7,14 @@ sys.path.append(str(Path(__file__).parent.parent / "src"))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cryptography.fernet import Fernet
from src.core.database import Base
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash
from src.scripts.create_admin import create_admin
from src.scripts.init_auth_db import ensure_encryption_key
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
@@ -189,3 +191,46 @@ def test_create_admin_is_idempotent_for_existing_user(monkeypatch, db_session):
assert created_user.email is None
assert verify_password("bootstrap-pass", created_user.password_hash)
assert not verify_password("new-password", created_user.password_hash)
def test_ensure_encryption_key_generates_backend_env_file(monkeypatch, tmp_path):
"""Test first-time initialization generates and persists a Fernet key."""
env_file = tmp_path / ".env"
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
generated_key = ensure_encryption_key(env_file)
assert generated_key
assert env_file.exists()
assert env_file.read_text(encoding="utf-8").strip() == f"ENCRYPTION_KEY={generated_key}"
assert verify_fernet_key(generated_key)
def test_ensure_encryption_key_reuses_existing_env_file_value(monkeypatch, tmp_path):
"""Test persisted key is reused without rewriting file contents."""
env_file = tmp_path / ".env"
existing_key = Fernet.generate_key().decode()
env_file.write_text(f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n", encoding="utf-8")
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
reused_key = ensure_encryption_key(env_file)
assert reused_key == existing_key
assert env_file.read_text(encoding="utf-8") == f"ENCRYPTION_KEY={existing_key}\nOTHER=value\n"
def test_ensure_encryption_key_prefers_process_environment(monkeypatch, tmp_path):
"""Test explicit process environment has priority over file generation."""
env_file = tmp_path / ".env"
runtime_key = Fernet.generate_key().decode()
monkeypatch.setenv("ENCRYPTION_KEY", runtime_key)
resolved_key = ensure_encryption_key(env_file)
assert resolved_key == runtime_key
assert not env_file.exists()
def verify_fernet_key(value: str) -> bool:
Fernet(value.encode())
return True

View File

@@ -4,7 +4,8 @@
"source_snapshot_ref": "v1.0.0-rc1",
"created_by": "operator",
"allowed_hosts": [
"internal-repo.company.com"
"rusal.ru",
"rusal.com"
],
"prohibited_artifact_categories": [
"test-data",

View File

@@ -234,10 +234,12 @@ source .venv/bin/activate
python src/scripts/init_auth_db.py
```
При первом запуске скрипт создаёт `backend/.env` и записывает туда `ENCRYPTION_KEY`, если ключ не был задан через окружение заранее.
### 2. Создание администратора
```bash
python src/scripts/create_admin.py --username admin --password admin
python src/scripts/create_admin.py --username admin --password '<strong-temporary-secret>'
```
**Важно**: После создания администратора измените пароль в продакшн-среде!

View File

@@ -0,0 +1,57 @@
# Security Remediation
## Immediate actions
1. Revoke and rotate any Gitea PAT previously stored in `backend/mappings.db`.
2. Rotate any secrets encrypted with historical `ENCRYPTION_KEY` values.
3. Reset affected local admin/test credentials if they ever existed outside disposable dev environments.
## Purge git history
The repository history contains binary databases with sensitive data. Rewrite history before treating the repository as clean.
Recommended targets:
- `backend/mappings.db`
- `backend/tasks.db`
- `backend/auth.db`
- `backend/backend/auth.db`
- `backend/test_auth_debug.py`
- `backend/test_decryption.py`
- `backend/test_encryption.py`
Example with `git filter-repo`:
```bash
git filter-repo \
--invert-paths \
--path backend/mappings.db \
--path backend/tasks.db \
--path backend/auth.db \
--path backend/backend/auth.db \
--path backend/test_auth_debug.py \
--path backend/test_decryption.py \
--path backend/test_encryption.py
```
After rewrite:
```bash
git for-each-ref --format='delete %(refname)' refs/original | git update-ref --stdin
git reflog expire --expire=now --all
git gc --prune=now --aggressive
git push --force --all
git push --force --tags
```
Everyone with old clones must re-clone or hard-reset to the rewritten history.
## Ongoing checks
Run:
```bash
./scripts/scan_secrets.sh
```
before release and before pushing history-rewrite results.

23
scripts/scan_secrets.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
echo "[scan] working tree patterns"
rg -nI \
--glob '!frontend/node_modules/**' \
--glob '!.svelte-kit/**' \
--glob '!dist/**' \
--glob '!build/**' \
'(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{35}|sk_live_[0-9A-Za-z]{16,}|sk_test_[0-9A-Za-z]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9\-_]{20,}|hf_[A-Za-z0-9]{20,}|-----BEGIN (RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----|https?://[^/@[:space:]]+:[^@[:space:]]+@|ENCRYPTION_KEY[[:space:]]*=|AUTH_SECRET_KEY[[:space:]]*=)' \
| rg -v 'oauth2:(token|secret)@' || true
echo "[scan] tracked env and database artifacts"
git ls-files | rg '(^|/)\.env($|\.)|(^|/).*\.(db|sqlite|pem|p12|pfx|crt|key)$' || true
echo "[scan] git history patterns"
git grep -nIE \
'(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{35}|sk_live_[0-9A-Za-z]{16,}|sk_test_[0-9A-Za-z]{16,}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9\-_]{20,}|hf_[A-Za-z0-9]{20,}|-----BEGIN (RSA |DSA |EC |OPENSSH |PGP )?PRIVATE KEY-----|https?://[^/@[:space:]]+:[^@[:space:]]+@|ENCRYPTION_KEY[[:space:]]*=|AUTH_SECRET_KEY[[:space:]]*=)' \
$(git rev-list --all) \
| rg -v 'oauth2:(token|secret)@' || true