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/tasks.db
backend/logs backend/logs
backend/auth.db backend/auth.db
semantics/reports semantics/reports
backend/tasks.db backend/tasks.db
backend/**/*.db
# Universal / tooling backend/**/*.sqlite
node_modules/
# Universal / tooling
node_modules/
.venv/ .venv/
coverage/ coverage/
*.tmp *.tmp

View File

@@ -151,8 +151,10 @@ cd backend
source .venv/bin/activate source .venv/bin/activate
python src/scripts/init_auth_db.py 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) ## 🏢 Enterprise Clean Deployment (internal-only)

View File

@@ -8,8 +8,10 @@
# @INVARIANT: Safe to run multiple times (idempotent). # @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS] # [SECTION: IMPORTS]
import os
import sys import sys
from pathlib import Path from pathlib import Path
from cryptography.fernet import Fernet
# Add src to path # Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent)) 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 from src.scripts.seed_permissions import seed_permissions
# [/SECTION] # [/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] # [DEF:run_init:Function]
# @PURPOSE: Main entry point for the initialization script. # @PURPOSE: Main entry point for the initialization script.
# @POST: auth.db is initialized with the correct schema and seeded permissions. # @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"): with belief_scope("init_auth_db"):
logger.info("Initializing authentication database...") logger.info("Initializing authentication database...")
try: try:
ensure_encryption_key()
init_db() init_db()
logger.info("Authentication database initialized successfully.") logger.info("Authentication database initialized successfully.")
@@ -40,4 +78,4 @@ def run_init():
if __name__ == "__main__": if __name__ == "__main__":
run_init() 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).""" """Construct EncryptionManager directly using Fernet (avoids relative import chain)."""
# Re-implement the same logic as EncryptionManager to avoid import issues # Re-implement the same logic as EncryptionManager to avoid import issues
# with the llm_provider module's relative imports # with the llm_provider module's relative imports
import os key = Fernet.generate_key()
key = os.getenv("ENCRYPTION_KEY", "REMOVED_HISTORICAL_SECRET_DO_NOT_USE").encode()
fernet = Fernet(key) fernet = Fernet(key)
class EncryptionManager: class EncryptionManager:
@@ -99,6 +98,18 @@ class TestEncryptionManager:
assert decrypted == "" assert decrypted == ""
# [/DEF:test_encrypt_empty_string:Function] # [/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] # [DEF:test_custom_key_roundtrip:Function]
# @PURPOSE: Custom Fernet key produces valid roundtrip. # @PURPOSE: Custom Fernet key produces valid roundtrip.
# @PRE: Generated Fernet key. # @PRE: Generated Fernet key.

View File

@@ -6,18 +6,35 @@
# @RELATION: DEPENDS_ON -> backend.src.core.database # @RELATION: DEPENDS_ON -> backend.src.core.database
# @RELATION: DEPENDS_ON -> backend.src.models.llm # @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 sqlalchemy.orm import Session
from ..models.llm import LLMProvider from ..models.llm import LLMProvider
from ..plugins.llm_analysis.models import LLMProviderConfig
from ..core.logger import belief_scope, logger from ..core.logger import belief_scope, logger
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
import os 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] # [DEF:EncryptionManager:Class]
# @TIER: CRITICAL # @TIER: CRITICAL
# @PURPOSE: Handles encryption and decryption of sensitive data like API keys. # @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 -> # @TEST_CONTRACT: EncryptionManagerModel ->
# { # {
@@ -33,10 +50,10 @@ import os
class EncryptionManager: class EncryptionManager:
# [DEF:EncryptionManager.__init__:Function] # [DEF:EncryptionManager.__init__:Function]
# @PURPOSE: Initialize the encryption manager with a Fernet key. # @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. # @POST: Fernet instance ready for encryption/decryption.
def __init__(self): 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) self.fernet = Fernet(self.key)
# [/DEF:EncryptionManager.__init__:Function] # [/DEF:EncryptionManager.__init__:Function]
@@ -97,7 +114,7 @@ class LLMProviderService:
# @PURPOSE: Creates a new LLM provider with encrypted API key. # @PURPOSE: Creates a new LLM provider with encrypted API key.
# @PRE: config must contain valid provider configuration. # @PRE: config must contain valid provider configuration.
# @POST: New provider created and persisted to database. # @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"): with belief_scope("create_provider"):
encrypted_key = self.encryption.encrypt(config.api_key) encrypted_key = self.encryption.encrypt(config.api_key)
db_provider = LLMProvider( db_provider = LLMProvider(
@@ -119,7 +136,7 @@ class LLMProviderService:
# @PURPOSE: Updates an existing LLM provider. # @PURPOSE: Updates an existing LLM provider.
# @PRE: provider_id must exist, config must be valid. # @PRE: provider_id must exist, config must be valid.
# @POST: Provider updated and persisted to database. # @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"): with belief_scope("update_provider"):
db_provider = self.get_provider(provider_id) db_provider = self.get_provider(provider_id)
if not db_provider: if not db_provider:
@@ -180,4 +197,4 @@ class LLMProviderService:
# [/DEF:LLMProviderService:Class] # [/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 import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from cryptography.fernet import Fernet
from src.core.database import Base from src.core.database import Base
from src.models.auth import User, Role, Permission, ADGroupMapping from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash from src.core.auth.security import verify_password, get_password_hash
from src.scripts.create_admin import create_admin 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 # Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:" 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 created_user.email is None
assert verify_password("bootstrap-pass", created_user.password_hash) assert verify_password("bootstrap-pass", created_user.password_hash)
assert not verify_password("new-password", 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", "source_snapshot_ref": "v1.0.0-rc1",
"created_by": "operator", "created_by": "operator",
"allowed_hosts": [ "allowed_hosts": [
"internal-repo.company.com" "rusal.ru",
"rusal.com"
], ],
"prohibited_artifact_categories": [ "prohibited_artifact_categories": [
"test-data", "test-data",

View File

@@ -234,10 +234,12 @@ source .venv/bin/activate
python src/scripts/init_auth_db.py python src/scripts/init_auth_db.py
``` ```
При первом запуске скрипт создаёт `backend/.env` и записывает туда `ENCRYPTION_KEY`, если ключ не был задан через окружение заранее.
### 2. Создание администратора ### 2. Создание администратора
```bash ```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