fix(securitate): hardening prod — headere, body-cap, non-root, backup
Findings security-review P1/P2 (2026-07-03): - headere de securitate pe toate raspunsurile (nosniff, X-Frame-Options, Referrer-Policy, HSTS doar pe HTTPS) + teste - body-cap global 10MB ca middleware ASGI pur (413 inainte de parserul multipart/JSON; verificarea per-endpoint ramane strat 2) - imagine Docker non-root (uid 10001), port 8010 aliniat, loguri pe volumul /data - fail-fast la boot cu rar_env=prod fara AUTOPASS_REQUIRE_API_KEY sau AUTOPASS_SESSION_SECRET - compose: env-uri critice obligatorii (:?) ca api/worker sa nu diverga tacit; FORWARDED_ALLOW_IPS ca rate-limit-ul sa vada IP-ul real dupa Traefik - signup fara PII in stdout: log_event in loc de print cu email (idem notify degradat) - ratelimit: sterge cheile fara timestamp-uri valide (crestere monotona a memoriei pe IP-uri reale) - backup criptat SQLite (backup online API, gpg AES256) + verificare restore + docs/backup.md Suita completa verde: 1557 passed, 1 skipped (live). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
37
.env.example
37
.env.example
@@ -8,20 +8,55 @@ AUTOPASS_CREDS_KEY=
|
||||
|
||||
# --- Auth API-key ---
|
||||
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
|
||||
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
|
||||
AUTOPASS_REQUIRE_API_KEY=false
|
||||
|
||||
# --- Worker ---
|
||||
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
|
||||
# OBLIGATORIU in docker-compose.yml (:?) — vezi nota din compose.
|
||||
AUTOPASS_WORKER_SEND_ENABLED=false
|
||||
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
||||
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||
|
||||
# --- RAR ---
|
||||
# test | prod
|
||||
# test | prod. Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e
|
||||
# per submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila asta
|
||||
# e doar fallback API pentru conturi fara medii disponibile, backfill la migrare
|
||||
# si afisaj dashboard. OBLIGATORIE in docker-compose.yml (:?) — vezi nota din compose.
|
||||
AUTOPASS_RAR_ENV=test
|
||||
|
||||
# --- Sesiuni web ---
|
||||
# Secret semnat cookie sesiune, PARTAJAT intre restart-uri (fara el, fiecare
|
||||
# redeploy delogheaza toti userii). Genereaza:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
# OBLIGATORIU in docker-compose.yml (:?).
|
||||
AUTOPASS_SESSION_SECRET=
|
||||
# True (prod, implicit in compose): cookie de sesiune cu flag Secure (necesita HTTPS).
|
||||
# False: doar pentru smoke-test local pe HTTP simplu (browserul dropeaza cookie-ul
|
||||
# Secure pe HTTP, deci login-ul ar esua silentios cu true).
|
||||
AUTOPASS_SESSION_HTTPS_ONLY=true
|
||||
|
||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
||||
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
|
||||
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
|
||||
AUTOPASS_EMBEDDINGS_ENABLED=false
|
||||
|
||||
# --- Backup SQLite criptat (T2/P0-4, vezi docs/backup.md) ---
|
||||
# TRIGGER DUR: backup-ul trebuie configurat INAINTE de prima declaratie reala in
|
||||
# prod. Baza e sistemul legal de evidenta (L.142/2023); FINALIZATA e terminal la
|
||||
# RAR, deci pierderea volumului fara backup produce duplicate necorectabile.
|
||||
#
|
||||
# Parola de criptare (gpg AES256) a backup-urilor — obligatorie, tools/backup_db.sh
|
||||
# refuza sa scrie un backup necriptat. Foloseste fisierul (secret montat), NU variabila
|
||||
# inline, in prod:
|
||||
AUTOPASS_BACKUP_PASSPHRASE_FILE=
|
||||
# Alternativa (ex. teste locale) — parola inline, evita in prod:
|
||||
AUTOPASS_BACKUP_PASSPHRASE=
|
||||
# Director destinatie backup-uri (pe volumul persistent, NU efemer).
|
||||
AUTOPASS_BACKUP_DIR=/data/backups
|
||||
# Cate backup-uri locale criptate se pastreaza (cele mai vechi se sterg).
|
||||
AUTOPASS_BACKUP_KEEP=14
|
||||
# Tinta rclone optionala pentru upload off-site (ex. remote:bucket/autopass). Fara ea,
|
||||
# backup-ul ramane doar local pe volum — sarit explicit cu log, nu esec silentios.
|
||||
AUTOPASS_BACKUP_RCLONE_REMOTE=
|
||||
|
||||
26
Dockerfile
26
Dockerfile
@@ -11,9 +11,19 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
WORKDIR /app
|
||||
|
||||
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
# gnupg = criptare backup SQLite (tools/backup_db.sh, T2/P0-4) — NU e inclus implicit
|
||||
# in imaginea slim (doar gzip e pachet esential Debian). rclone NU e instalat aici
|
||||
# intentionat: upload-ul remote e optional (AUTOPASS_BACKUP_RCLONE_REMOTE) si scriptul
|
||||
# sare peste el explicit daca binarul lipseste.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# User neprivilegiat (uid/gid stabil): orice RCE viitor are raza de explozie
|
||||
# limitata, nu root in container. HOME e necesar explicit — fastembed descarca
|
||||
# modelul (~230MB) in $HOME la prima folosire (AUTOPASS_EMBEDDINGS_ENABLED=true).
|
||||
RUN groupadd -g 10001 app && useradd -u 10001 -g app -d /home/app -m app
|
||||
ENV HOME=/home/app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -22,9 +32,19 @@ COPY tools ./tools
|
||||
|
||||
# Date persistente (SQLite WAL) pe volum montat.
|
||||
ENV AUTOPASS_DB_PATH=/data/autopass.db
|
||||
# Log text rotativ (app/config.py:log_dir, RotatingFileHandler) pe acelasi volum
|
||||
# persistent, NU in /app/.run — altfel non-root n-are voie sa scrie in /app.
|
||||
ENV AUTOPASS_LOG_DIR=/data/logs
|
||||
|
||||
# chown INAINTE de VOLUME — schimbarile de proprietar dupa directiva VOLUME se
|
||||
# pierd la build. Migrare one-off pentru un volum EXISTENT deja detinut de root:
|
||||
# docker compose run --rm --user root api chown -R app:app /data
|
||||
RUN mkdir -p /data && chown app:app /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 8000
|
||||
USER app
|
||||
|
||||
EXPOSE 8010
|
||||
|
||||
# Default = API. Worker-ul suprascrie command in compose.
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||
|
||||
@@ -139,6 +139,12 @@ class Settings(BaseSettings):
|
||||
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||
seed_operatii_enabled: bool = True
|
||||
|
||||
# --- Limita globala corp cerere (P1-4/P1-5, hardening 2026-07-03) ---
|
||||
# Plafon aplicat de un middleware ASGI pur (app/web/body_cap.py) pe TOATE rutele,
|
||||
# INAINTE ca FastAPI sa parseze body-ul (multipart e spooled pe disc altfel).
|
||||
# Verificarea per-endpoint existenta (import_parse.MAX_BYTES, 5MB) ramane strat 2.
|
||||
max_request_bytes: int = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
@property
|
||||
def rar_base_url(self) -> str:
|
||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
||||
@@ -149,6 +155,32 @@ def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
def validate_prod_invariants(settings: Settings) -> None:
|
||||
"""Fail-fast la startup: refuza boot-ul in productie cand lipsesc invariantele
|
||||
de securitate obligatorii, in loc sa lase aplicatia sa porneasca intr-o
|
||||
configuratie nesigura descoperita abia dupa deploy (mirror `crypto.validate_creds_key`).
|
||||
|
||||
Conditionat STRICT pe `rar_env=="prod"` — altfel ar opri suita de teste, care
|
||||
ruleaza implicit cu `rar_env="test"` si fara `session_secret`/`require_api_key`.
|
||||
"""
|
||||
if settings.rar_env != "prod":
|
||||
return
|
||||
if not settings.require_api_key:
|
||||
raise RuntimeError(
|
||||
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_REQUIRE_API_KEY nu e activat: in "
|
||||
"productie cheia API e obligatorie pe /v1/*, altfel orice cerere "
|
||||
"neautentificata ajunge pe contul implicit id=1. Seteaza "
|
||||
"AUTOPASS_REQUIRE_API_KEY=true."
|
||||
)
|
||||
if not settings.session_secret:
|
||||
raise RuntimeError(
|
||||
"AUTOPASS_RAR_ENV=prod dar AUTOPASS_SESSION_SECRET nu este setat: fara el, "
|
||||
"secretul de sesiune e efemer per restart si toti userii sunt delogati la "
|
||||
"fiecare redeploy. Genereaza unul persistent cu:\n"
|
||||
" python3 -c \"import secrets; print(secrets.token_hex(32))\""
|
||||
)
|
||||
|
||||
|
||||
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
|
||||
"""Citeste credentialele <test> din settings.xml (dev local / probe test).
|
||||
|
||||
|
||||
10
app/email.py
10
app/email.py
@@ -11,6 +11,7 @@ import textwrap
|
||||
from email.message import EmailMessage
|
||||
|
||||
from .config import get_settings
|
||||
from .observ import log_event
|
||||
|
||||
|
||||
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
||||
@@ -23,10 +24,11 @@ def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.smtp_host or not admin_emails:
|
||||
print(
|
||||
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
|
||||
f"email={email} admins={len(admin_emails)}",
|
||||
flush=True,
|
||||
log_event(
|
||||
"signup_notify_degradat",
|
||||
account_id=account_id,
|
||||
mesaj="fara SMTP configurat",
|
||||
context={"admins": len(admin_emails)},
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
14
app/main.py
14
app/main.py
@@ -25,12 +25,13 @@ from . import errors
|
||||
from .api.v1.import_router import router as import_v1_router
|
||||
from .api.v1.integrare_router import router as integrare_v1_router
|
||||
from .api.v1.router import router as api_v1_router
|
||||
from .config import get_settings
|
||||
from .config import get_settings, validate_prod_invariants
|
||||
from .crypto import validate_creds_key
|
||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||
from .observ import log_event, request_id_var
|
||||
from .security import install_log_redaction, scrub_text
|
||||
from .web.middleware import RequestIDMiddleware
|
||||
from .web.body_cap import BodyCapMiddleware
|
||||
from .web.middleware import RequestIDMiddleware, SecurityHeadersMiddleware
|
||||
from .web.routes import router as web_router
|
||||
from .web.auth_routes import router as auth_router
|
||||
from .web.admin_routes import router as admin_router
|
||||
@@ -44,6 +45,9 @@ async def lifespan(app: FastAPI):
|
||||
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
||||
# in loc de 500 brut la primul POST /v1/prezentari.
|
||||
validate_creds_key()
|
||||
# Fail-fast (prod-only): elimina clasa "am uitat env var in Dokploy" pentru
|
||||
# cheia API si secretul de sesiune, in loc de o instanta descoperita post-deploy.
|
||||
validate_prod_invariants(get_settings())
|
||||
init_db()
|
||||
yield
|
||||
|
||||
@@ -63,6 +67,12 @@ app.add_middleware(
|
||||
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
||||
# inclusiv 401/404/422/500 produse mai in interior.
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
# Headere de securitate (P1-4) pe TOATE raspunsurile.
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
# Body-cap (P1-5): ADAUGAT ULTIMUL -> ruleaza CEL MAI OUTERMOST (add_middleware
|
||||
# prepend), deci intercepteaza `receive` inaintea oricarui parser FastAPI/Starlette
|
||||
# (multipart, JSON) — vezi docstring body_cap.py pentru motivul ASGI-pur.
|
||||
app.add_middleware(BodyCapMiddleware)
|
||||
|
||||
|
||||
@app.exception_handler(LoginRequired)
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..auth import create_api_key
|
||||
from ..config import get_settings
|
||||
from ..db import get_connection
|
||||
from ..email import notify_signup
|
||||
from ..observ import log_event
|
||||
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from ..web.ratelimit import check_rate_limit
|
||||
@@ -157,7 +158,7 @@ async def signup_post(
|
||||
conn.close()
|
||||
|
||||
set_session(request, account_id, user_id)
|
||||
print(f"SIGNUP cont={account_id} email={email}", flush=True)
|
||||
log_event("signup", account_id=account_id, mesaj="cont nou creat")
|
||||
|
||||
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
||||
try:
|
||||
|
||||
93
app/web/body_cap.py
Normal file
93
app/web/body_cap.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Middleware ASGI PUR: plafon global pe dimensiunea corpului cererii (P1-5).
|
||||
|
||||
`BaseHTTPMiddleware` nu poate refuza devreme: pentru a construi un `Request`
|
||||
reconstituie intregul flux `receive()`, iar FastAPI parseaza deja COMPLET
|
||||
multipart-ul (spooled pe disc peste prag) INAINTE sa ruleze endpointul — o
|
||||
verificare per-endpoint (chiar si citire pe chunk-uri) nu repara cauza, doar
|
||||
simptomul. Un middleware ASGI pur intercepteaza `receive` inaintea oricarui
|
||||
parser: Content-Length peste limita -> 413 fara sa citeasca nimic din body;
|
||||
altfel numara cumulativ octetii mesajelor `http.request` si intrerupe la
|
||||
depasire. Cererile fara body (GET etc.) trec netaxate.
|
||||
|
||||
Verificarea per-endpoint existenta (`import_router.py`, `import_parse.MAX_BYTES`)
|
||||
ramane NEATINSA ca strat 2 — acopera orice cale care ar ocoli acest middleware.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from .. import errors
|
||||
from ..config import get_settings
|
||||
|
||||
|
||||
class _BodyTooLarge(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _error_body() -> bytes:
|
||||
payload = errors.eroare(
|
||||
"CERERE_PREA_MARE",
|
||||
cauza="Corpul cererii depaseste limita permisa.",
|
||||
)
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
class BodyCapMiddleware:
|
||||
"""Limiteaza corpul cererii la `settings.max_request_bytes`, pe TOATE rutele.
|
||||
|
||||
Limita se citeste din `get_settings()` LA FIECARE CERERE (nu o data la
|
||||
construirea middleware-ului): `app.add_middleware` ruleaza o singura data per
|
||||
proces (modulul `app.main` e importat o singura data si in teste), deci un
|
||||
`max_bytes` fixat in `__init__` ar ramane inghetat la valoarea de la primul
|
||||
import si ar ignora `AUTOPASS_MAX_REQUEST_BYTES` schimbat ulterior (teste).
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
max_bytes = get_settings().max_request_bytes
|
||||
|
||||
content_length = Headers(scope=scope).get("content-length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
declared = int(content_length)
|
||||
except ValueError:
|
||||
declared = None
|
||||
if declared is not None and declared > max_bytes:
|
||||
await self._send_413(send)
|
||||
return
|
||||
|
||||
total = 0
|
||||
|
||||
async def capped_receive():
|
||||
nonlocal total
|
||||
message = await receive()
|
||||
if message["type"] == "http.request":
|
||||
total += len(message.get("body") or b"")
|
||||
if total > max_bytes:
|
||||
raise _BodyTooLarge()
|
||||
return message
|
||||
|
||||
try:
|
||||
await self.app(scope, capped_receive, send)
|
||||
except _BodyTooLarge:
|
||||
await self._send_413(send)
|
||||
|
||||
@staticmethod
|
||||
async def _send_413(send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 413,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": _error_body()})
|
||||
@@ -31,3 +31,23 @@ class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
request_id_var.reset(token)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Headere de securitate pe TOATE raspunsurile (P1-4, hardening 2026-07-03).
|
||||
|
||||
HSTS se pune DOAR pe HTTPS: pe HTTP browserul l-ar ignora oricum, dar l-am
|
||||
omis explicit ca sa nu sugereze o garantie falsa in dev/smoke pe HTTP simplu.
|
||||
Nu suprascrie un header deja setat explicit de raspuns (setdefault).
|
||||
"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
if request.url.scheme == "https":
|
||||
response.headers.setdefault(
|
||||
"Strict-Transport-Security", "max-age=31536000; includeSubDomains"
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -7,10 +7,9 @@ Configurabil prin AUTOPASS_signup_rate_max / AUTOPASS_signup_rate_window_s (conf
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
||||
_hits: dict[str, list[float]] = defaultdict(list)
|
||||
_hits: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
||||
@@ -19,13 +18,21 @@ def check_rate_limit(key: str, max_hits: int, window_s: int) -> bool:
|
||||
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
||||
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
||||
suficient pentru un singur proces uvicorn.
|
||||
|
||||
Cheile fara timestamp-uri valide sunt sterse din `_hits` (nu doar golite), altfel
|
||||
dictionarul creste monoton pe viata procesului odata ce cheia devine IP-uri reale
|
||||
de internet (F5). Foloseste `.get` in loc de acces direct pe dict pentru a nu
|
||||
reintroduce o cheie doar prin citire.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - window_s
|
||||
timestamps = _hits[key]
|
||||
# Sterge intrari expirate
|
||||
_hits[key] = [t for t in timestamps if t > cutoff]
|
||||
if len(_hits[key]) >= max_hits:
|
||||
filtered = [t for t in _hits.get(key, []) if t > cutoff]
|
||||
if len(filtered) >= max_hits:
|
||||
if filtered:
|
||||
_hits[key] = filtered
|
||||
else:
|
||||
_hits.pop(key, None)
|
||||
return False
|
||||
_hits[key].append(now)
|
||||
filtered.append(now)
|
||||
_hits[key] = filtered
|
||||
return True
|
||||
|
||||
@@ -13,15 +13,35 @@ services:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
# Override din environment (Dokploy) pentru staging; default = prod.
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
|
||||
# Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e per
|
||||
# submission/cont (worker-ul trimite dupa submissions.rar_env). Variabila
|
||||
# asta serveste doar ca fallback API pentru conturi fara medii disponibile,
|
||||
# backfill la migrare (db.py) si afisaj dashboard (routes.py). Obligatorie
|
||||
# ca sa nu difere tacit intre api si worker (vezi worker mai jos).
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
|
||||
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
||||
TZ: ${TZ:-Europe/Bucharest}
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:-false}
|
||||
AUTOPASS_REQUIRE_API_KEY: ${AUTOPASS_REQUIRE_API_KEY:?seteaza AUTOPASS_REQUIRE_API_KEY (true in prod) in .env (vezi .env.example)}
|
||||
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
|
||||
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
||||
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
|
||||
# uvicorn 0.30.0 are proxy_headers=True implicit; lipsea doar increderea in
|
||||
# sursa headerelor, altfel request.client.host = IP-ul Traefik pentru toti
|
||||
# vizitatorii (rate-limit login/signup devine o galeata GLOBALA). Uvicorn
|
||||
# citeste FORWARDED_ALLOW_IPS direct din mediu (fara flag CLI necesar).
|
||||
# ATENTIE, doua invariante trebuie sa ramana adevarate impreuna cu "*":
|
||||
# (1) serviciul api NU publica `ports:` pe host aici — accesibil DOAR prin Traefik;
|
||||
# (2) Traefik NU e configurat cu forwardedHeaders.insecure=true.
|
||||
# Daca (2) e incalcat, un atacator poate falsifica X-Forwarded-For si ocoli
|
||||
# rate-limit-ul (galeata noua per cerere = brute-force nelimitat).
|
||||
FORWARDED_ALLOW_IPS: "*"
|
||||
# Fara secret persistent, fiecare redeploy delogheaza toti userii (main.py
|
||||
# genereaza unul efemer la runtime). HTTPS_ONLY=true e corect pentru prod,
|
||||
# dar inseamna ca un smoke-test simplu pe HTTP NU poate face login (browserul
|
||||
# dropeaza cookie-ul Secure) — pe HTTP local seteaza explicit false.
|
||||
AUTOPASS_SESSION_SECRET: ${AUTOPASS_SESSION_SECRET:?seteaza AUTOPASS_SESSION_SECRET in .env (vezi .env.example)}
|
||||
AUTOPASS_SESSION_HTTPS_ONLY: ${AUTOPASS_SESSION_HTTPS_ONLY:-true}
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
|
||||
@@ -36,11 +56,15 @@ services:
|
||||
- autopass-data:/data
|
||||
environment:
|
||||
AUTOPASS_DB_PATH: /data/autopass.db
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-test}
|
||||
# Vezi comentariul de la serviciul api: ancora globala, NU tinta trimiterilor
|
||||
# (worker-ul trimite dupa submissions.rar_env, per cont). Aceeasi variabila
|
||||
# obligatorie AICI, ca sa nu diverga tacit fata de api (era prod/test split-brain).
|
||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:?seteaza AUTOPASS_RAR_ENV (test|prod) in .env (vezi .env.example)}
|
||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
||||
# Send activ by default (prod); pe staging seteaza AUTOPASS_WORKER_SEND_ENABLED=false
|
||||
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
||||
# Send catre RAR. Obligatoriu explicit — fara ea, un misconfig ar putea lasa
|
||||
# worker-ul sa trimita declaratii reale neintentionat (Legea 142/2023).
|
||||
# false = nu trimite (sigur pentru probe). true = end-to-end.
|
||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:?seteaza AUTOPASS_WORKER_SEND_ENABLED (true|false) in .env (vezi .env.example)}
|
||||
restart: always
|
||||
depends_on:
|
||||
- api
|
||||
@@ -68,5 +92,33 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
# Backup SQLite criptat (T2/P0-4, evidenta legala L.142/2023) — vezi docs/backup.md.
|
||||
# Serviciu OPTIONAL, profil "backup": nu porneste la `docker compose up` normal.
|
||||
# Activare: `docker compose --profile backup up -d backup` (sau COMPOSE_PROFILES=backup
|
||||
# in mediul Dokploy). Alternativa fara profil compose: cron pe host care ruleaza
|
||||
# `docker compose exec api bash tools/backup_db.sh` — mai simpla daca ai deja acces SSH
|
||||
# la host, dar in Dokploy hostul poate fi gestionat/efemer, de aceea preferam varianta
|
||||
# de mai jos (calatoreste cu image-ul, nu depinde de un cron configurat manual pe VM).
|
||||
# backup:
|
||||
# build: .
|
||||
# profiles: ["backup"]
|
||||
# entrypoint: ["bash", "-c"]
|
||||
# command:
|
||||
# - |
|
||||
# while true; do
|
||||
# bash tools/backup_db.sh || echo "[backup] rulare esuata, reincerc la urmatorul ciclu"
|
||||
# sleep 86400
|
||||
# done
|
||||
# volumes:
|
||||
# - autopass-data:/data
|
||||
# environment:
|
||||
# AUTOPASS_DB_PATH: /data/autopass.db
|
||||
# AUTOPASS_BACKUP_DIR: ${AUTOPASS_BACKUP_DIR:-/data/backups}
|
||||
# AUTOPASS_BACKUP_KEEP: ${AUTOPASS_BACKUP_KEEP:-14}
|
||||
# AUTOPASS_BACKUP_PASSPHRASE_FILE: ${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}
|
||||
# AUTOPASS_BACKUP_PASSPHRASE: ${AUTOPASS_BACKUP_PASSPHRASE:-}
|
||||
# AUTOPASS_BACKUP_RCLONE_REMOTE: ${AUTOPASS_BACKUP_RCLONE_REMOTE:-}
|
||||
# restart: always
|
||||
|
||||
volumes:
|
||||
autopass-data:
|
||||
|
||||
99
docs/backup.md
Normal file
99
docs/backup.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Backup SQLite (T2/P0-4)
|
||||
|
||||
## Ce se salveaza si de ce
|
||||
|
||||
Baza SQLite (`AUTOPASS_DB_PATH`, prod `/data/autopass.db`) e sistemul legal de evidenta
|
||||
al declaratiilor RAR AUTOPASS (Legea 142/2023). `FINALIZATA` e **terminal** la RAR — nu
|
||||
exista anulare/corectie prin API. Pierderea volumului fara backup inseamna, la restaurare
|
||||
dintr-un mediu gol, retrimiterea acelorasi randuri si duplicate pe care RAR le accepta
|
||||
fara sa poata fi corectate. De aceea backup-ul e trigger dur: **configureaza-l inainte
|
||||
de prima declaratie reala in prod**, nu dupa.
|
||||
|
||||
Backup-urile sunt criptate (gpg AES256) pentru ca snapshot-ul contine PII criptat Fernet
|
||||
(nume, VIN, date client) plus metadate — un backup necriptat pe disc/remote ar fi un al
|
||||
doilea loc unde datele astea pot scapa.
|
||||
|
||||
## Rulare manuala
|
||||
|
||||
```bash
|
||||
# in container (api sau worker, acelasi image + volum):
|
||||
docker compose exec api bash tools/backup_db.sh
|
||||
|
||||
# local (dev), cu parola de test:
|
||||
AUTOPASS_DB_PATH=./data/autopass.db \
|
||||
AUTOPASS_BACKUP_DIR=./data/backups \
|
||||
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola \
|
||||
bash tools/backup_db.sh
|
||||
```
|
||||
|
||||
Scriptul foloseste `sqlite3.Connection.backup` (online, sigur cu WAL activ — NU `cp`),
|
||||
comprima (gzip) si cripteaza (gpg simetric). Fara `AUTOPASS_BACKUP_PASSPHRASE_FILE` sau
|
||||
`AUTOPASS_BACKUP_PASSPHRASE` seteaza, scriptul **refuza** sa scrie un backup necriptat si
|
||||
iese cu cod diferit de 0. Pastreaza ultimele `AUTOPASS_BACKUP_KEEP` (implicit 14) fisiere
|
||||
in `AUTOPASS_BACKUP_DIR` (implicit `/data/backups`, pe volumul persistent). Daca
|
||||
`AUTOPASS_BACKUP_RCLONE_REMOTE` e setat si `rclone` e disponibil, urca automat
|
||||
backup-ul nou catre remote (off-site); altfel sare peste, cu log explicit.
|
||||
|
||||
## Programare
|
||||
|
||||
Doua variante, alege una:
|
||||
|
||||
1. **Serviciu compose optional** (`docker-compose.yml`, serviciu `backup`, profil
|
||||
`backup`, comentat implicit) — ruleaza scriptul intr-o bucla cu `sleep 86400` in
|
||||
propriul container, pe acelasi volum `autopass-data`. Activare:
|
||||
```bash
|
||||
docker compose --profile backup up -d backup
|
||||
```
|
||||
sau, in Dokploy, seteaza `COMPOSE_PROFILES=backup` in mediul aplicatiei. Preferata
|
||||
pentru un deploy Dokploy: calatoreste cu image-ul si nu depinde de acces SSH separat
|
||||
la hostul care ruleaza containerele (host care in Dokploy poate fi gestionat/efemer).
|
||||
2. **Cron pe host** — daca ai acces SSH direct la masina care ruleaza containerele:
|
||||
```cron
|
||||
0 3 * * * cd /cale/catre/autopass && docker compose exec -T api bash tools/backup_db.sh
|
||||
```
|
||||
|
||||
In ambele cazuri, parola de criptare trebuie sa fie accesibila containerului (secret
|
||||
montat, indicat prin `AUTOPASS_BACKUP_PASSPHRASE_FILE`) — nu doar in mediul shell al
|
||||
cron-ului de pe host.
|
||||
|
||||
## Restaurare (pas cu pas)
|
||||
|
||||
1. **Opreste worker-ul** (`docker compose stop worker`) — altfel worker-ul poate scrie
|
||||
in baza in timp ce o inlocuiesti, sau poate re-prelua randuri `sending` in timp ce
|
||||
restaurarea e pe jumatate facuta.
|
||||
2. Alege backup-ul de restaurat (fisier `autopass-YYYYmmdd-HHMMSS.db.gz.gpg` din
|
||||
`AUTOPASS_BACKUP_DIR`).
|
||||
3. Decripteaza + dezarhiveaza intr-un fisier temporar (NU suprascrie direct baza vie):
|
||||
```bash
|
||||
gpg --batch --decrypt --passphrase-file /cale/catre/parola \
|
||||
--output /tmp/restore.db.gz autopass-20260706-030000.db.gz.gpg
|
||||
gzip -d /tmp/restore.db.gz
|
||||
```
|
||||
4. Verifica integritatea inainte sa promovezi fisierul (vezi sectiunea urmatoare).
|
||||
5. Muta fisierul verificat peste `AUTOPASS_DB_PATH` (backup-uieste intai baza curenta
|
||||
daca mai exista, chiar corupta — pastreaz-o pentru investigare).
|
||||
6. Porneste din nou worker-ul (`docker compose start worker`).
|
||||
|
||||
## Verificare (restore_check)
|
||||
|
||||
Un backup neverificat nu e backup. `tools/restore_check.sh` decripteaza + dezarhiveaza
|
||||
cel mai recent backup (sau unul explicit, ca argument) intr-un fisier temporar si ruleaza
|
||||
`PRAGMA integrity_check` + `SELECT count(*) FROM submissions`. Iese cu cod diferit de 0
|
||||
daca decriptarea, integritatea sau interogarea esueaza.
|
||||
|
||||
```bash
|
||||
AUTOPASS_BACKUP_PASSPHRASE_FILE=/cale/catre/parola tools/restore_check.sh
|
||||
# sau explicit:
|
||||
tools/restore_check.sh /data/backups/autopass-20260706-030000.db.gz.gpg
|
||||
```
|
||||
|
||||
Ruleaza-l periodic (ex. dupa fiecare backup programat) — un backup care nu se poate
|
||||
restaura si verifica nu ofera nicio garantie reala.
|
||||
|
||||
## Unde stau parolele
|
||||
|
||||
Parola de criptare (`AUTOPASS_BACKUP_PASSPHRASE_FILE`) e un secret separat de
|
||||
`AUTOPASS_CREDS_KEY` (Fernet, creds RAR) — nu le refolosi una pe cealalta. In prod,
|
||||
monteaz-o ca fisier secret (nu variabila de mediu inline in `.env` necriptat pe disc)
|
||||
si restrictioneaza accesul la fisier. `AUTOPASS_BACKUP_PASSPHRASE` (variabila inline)
|
||||
e doar pentru teste locale/CI, nu pentru prod.
|
||||
64
tests/test_body_cap.py
Normal file
64
tests/test_body_cap.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Teste P1-5 (hardening 2026-07-03): plafon global corp cerere (BodyCapMiddleware)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "bc.db"))
|
||||
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
|
||||
# Prag mic ca sa nu generam MB-uri reale in test.
|
||||
monkeypatch.setenv("AUTOPASS_MAX_REQUEST_BYTES", "1000")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_multipart_peste_limita_413(client):
|
||||
data = b"x" * 5000
|
||||
r = client.post(
|
||||
"/v1/import",
|
||||
files={"file": ("mare.csv", io.BytesIO(data), "text/csv")},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
assert r.json()["cod"] == "CERERE_PREA_MARE"
|
||||
|
||||
|
||||
def test_json_peste_limita_413(client):
|
||||
payload = {"blob": "x" * 5000}
|
||||
r = client.post(
|
||||
"/v1/prezentari",
|
||||
content=json.dumps(payload),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
assert r.json()["cod"] == "CERERE_PREA_MARE"
|
||||
|
||||
|
||||
def test_cerere_normala_sub_limita_trece(client):
|
||||
r = client.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_content_length_mare_body_mic_413_devreme(client):
|
||||
"""Content-Length declarat peste limita respinge inainte sa citeasca body-ul,
|
||||
chiar daca body-ul trimis efectiv e mic (client "mincinos")."""
|
||||
r = client.post(
|
||||
"/v1/prezentari",
|
||||
content=b"{}",
|
||||
headers={"content-type": "application/json", "content-length": "999999"},
|
||||
)
|
||||
assert r.status_code == 413
|
||||
assert r.json()["cod"] == "CERERE_PREA_MARE"
|
||||
@@ -39,6 +39,9 @@ def client_prod(monkeypatch):
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "t.db"))
|
||||
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
|
||||
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
|
||||
# rar_env=prod declanseaza invarianta de startup (validate_prod_invariants,
|
||||
# hardening 2026-07-03): cere si session_secret setat, altfel boot-ul refuza.
|
||||
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
77
tests/test_ratelimit_cleanup.py
Normal file
77
tests/test_ratelimit_cleanup.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Teste F5: `_hits` nu creste monoton pe viata procesului.
|
||||
|
||||
Cheile ale caror timestamp-uri au expirat complet trebuie sterse din `_hits`
|
||||
(nu doar golite), altfel dictionarul acumuleaza o intrare per IP vazut vreodata.
|
||||
Semantica ferestrei glisante (permite/blocheaza) trebuie sa ramana neschimbata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.web import ratelimit
|
||||
from app.web.ratelimit import check_rate_limit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_hits():
|
||||
ratelimit._hits.clear()
|
||||
yield
|
||||
ratelimit._hits.clear()
|
||||
|
||||
|
||||
def test_cheie_blocata_fara_timestamp_uri_valide_e_stearsa(monkeypatch):
|
||||
"""max_hits=0 -> orice cerere e blocata; daca fereastra a expirat complet
|
||||
(filtrarea produce o lista goala), cheia nu trebuie sa ramana in `_hits`."""
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||
|
||||
key = "ip_blocat_gol"
|
||||
assert check_rate_limit(key, max_hits=0, window_s=60) is False
|
||||
# Nimic adaugat (max_hits=0 blocheaza tot) si lista era goala -> cheia disparuta.
|
||||
assert key not in ratelimit._hits
|
||||
|
||||
|
||||
def test_cheie_activa_ramane_dupa_permis(monkeypatch):
|
||||
"""O cerere permisa isi adauga timestamp-ul -> cheia activa ramane in `_hits`."""
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||
|
||||
key = "ip_activ"
|
||||
assert check_rate_limit(key, max_hits=2, window_s=60) is True
|
||||
assert key in ratelimit._hits
|
||||
assert ratelimit._hits[key] == [1000.0]
|
||||
|
||||
|
||||
def test_semantica_max_hits_si_fereastra_neschimbata(monkeypatch):
|
||||
"""max_hits atinse -> False; dupa expirarea ferestrei -> True din nou."""
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||
|
||||
key = "ip_fereastra"
|
||||
assert check_rate_limit(key, max_hits=2, window_s=10) is True
|
||||
assert check_rate_limit(key, max_hits=2, window_s=10) is True
|
||||
# A treia cerere in aceeasi fereastra -> blocata.
|
||||
assert check_rate_limit(key, max_hits=2, window_s=10) is False
|
||||
|
||||
# Trece timpul peste fereastra -> toate timestamp-urile expira.
|
||||
now[0] += 11
|
||||
assert check_rate_limit(key, max_hits=2, window_s=10) is True
|
||||
# Doar noua cerere ramane in lista (cele vechi, expirate, au fost curatate).
|
||||
assert ratelimit._hits[key] == [1011.0]
|
||||
|
||||
|
||||
def test_cheie_dispare_dupa_expirare_completa_si_reblocare(monkeypatch):
|
||||
"""O cheie blocata, ramasa fara timestamp-uri valide dupa expirarea ferestrei
|
||||
(fara sa mai apara alte cereri intre timp), nu trebuie sa ramana in `_hits`
|
||||
la urmatorul apel care o gaseste goala si tot o blocheaza (max_hits=0)."""
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||
|
||||
key = "ip_reblocat"
|
||||
check_rate_limit(key, max_hits=0, window_s=5)
|
||||
assert key not in ratelimit._hits
|
||||
|
||||
now[0] += 100
|
||||
assert check_rate_limit(key, max_hits=0, window_s=5) is False
|
||||
assert key not in ratelimit._hits
|
||||
64
tests/test_security_headers.py
Normal file
64
tests/test_security_headers.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Teste P1-4 (hardening 2026-07-03): security headers pe TOATE raspunsurile."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "sh.db"))
|
||||
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def _assert_common_headers(headers) -> None:
|
||||
assert headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert headers.get("X-Frame-Options") == "DENY"
|
||||
assert headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
|
||||
|
||||
def test_headere_pe_raspuns_200(client):
|
||||
r = client.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
_assert_common_headers(r.headers)
|
||||
|
||||
|
||||
def test_headere_pe_raspuns_404(client):
|
||||
r = client.get("/o/ruta/care/nu/exista")
|
||||
assert r.status_code == 404
|
||||
_assert_common_headers(r.headers)
|
||||
|
||||
|
||||
def test_hsts_absent_pe_http(client):
|
||||
r = client.get("/healthz")
|
||||
assert "Strict-Transport-Security" not in r.headers
|
||||
|
||||
|
||||
def test_hsts_prezent_pe_https():
|
||||
tmp = tempfile.mkdtemp()
|
||||
import os as _os
|
||||
|
||||
_os.environ["AUTOPASS_DB_PATH"] = _os.path.join(tmp, "sh_https.db")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app, base_url="https://testserver") as c:
|
||||
r = c.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("Strict-Transport-Security") == (
|
||||
"max-age=31536000; includeSubDomains"
|
||||
)
|
||||
get_settings.cache_clear()
|
||||
_os.environ.pop("AUTOPASS_DB_PATH", None)
|
||||
@@ -155,16 +155,33 @@ def test_primul_signup_devine_admin(client):
|
||||
# Teste C16 (log SIGNUP pastrat) si best-effort E2E
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_signup_inca_logheaza_si_notifica(client, capsys):
|
||||
"""Signup reusit -> stdout contine 'SIGNUP cont=' (C16 pastrat)."""
|
||||
def test_signup_logheaza_fara_pii_in_stdout(client, capsys):
|
||||
"""Signup reusit -> NU se mai printeaza email-ul in clar in stdout (P2-7).
|
||||
|
||||
C16 (jurnalizarea signup-ului) e pastrata, dar prin `log_event` (app_events +
|
||||
log text redactat), nu prin print() cu PII."""
|
||||
resp = _do_signup(client, "Service Log Test", "log@test.com")
|
||||
assert resp.status_code == 200
|
||||
assert "rfak_" in resp.text
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "SIGNUP cont=" in captured.out, (
|
||||
f"Linia de log C16 'SIGNUP cont=' lipseste din stdout. Capturat: {captured.out!r}"
|
||||
assert "log@test.com" not in captured.out, (
|
||||
f"Emailul nu trebuie sa apara in clar in stdout. Capturat: {captured.out!r}"
|
||||
)
|
||||
assert "SIGNUP cont=" not in captured.out, (
|
||||
"Print-ul vechi cu PII nu mai trebuie sa existe."
|
||||
)
|
||||
|
||||
from app.db import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT account_id, tip FROM app_events WHERE tip='signup' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
assert row is not None, "Trebuie sa existe un eveniment 'signup' in app_events"
|
||||
assert row["tip"] == "signup"
|
||||
|
||||
|
||||
def test_signup_neblocat_de_notify(monkeypatch, client):
|
||||
|
||||
86
tests/test_startup_invariant.py
Normal file
86
tests/test_startup_invariant.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Teste E1 (hardening 2026-07-03): invarianta de startup fail-fast, prod-only.
|
||||
|
||||
`validate_prod_invariants` (app/config.py) trebuie sa refuze boot-ul cand
|
||||
`rar_env=="prod"` si lipseste `require_api_key` sau `session_secret` — dar sa
|
||||
ramana un no-op pe `rar_env` implicit ("test"), altfel ar opri toata suita.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _clear(monkeypatch):
|
||||
# NU delenv REQUIRE_API_KEY: `.env` real (dev) are AUTOPASS_REQUIRE_API_KEY=true,
|
||||
# iar conftest il neutralizeaza cu os.environ.setdefault("...", "false") — un delenv
|
||||
# aici ar re-expune valoarea din `.env` (precedenta pydantic-settings: env var > .env
|
||||
# file). Fiecare test seteaza explicit ce valoare vrea pentru acest flag.
|
||||
monkeypatch.delenv("AUTOPASS_RAR_ENV", raising=False)
|
||||
monkeypatch.delenv("AUTOPASS_SESSION_SECRET", raising=False)
|
||||
|
||||
|
||||
def test_prod_fara_require_api_key_refuza_boot(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
|
||||
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "false")
|
||||
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with pytest.raises(RuntimeError, match="AUTOPASS_REQUIRE_API_KEY"):
|
||||
with TestClient(app):
|
||||
pass
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_prod_fara_session_secret_refuza_boot(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
|
||||
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with pytest.raises(RuntimeError, match="AUTOPASS_SESSION_SECRET"):
|
||||
with TestClient(app):
|
||||
pass
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_prod_cu_invarianta_satisfacuta_boot_ok(monkeypatch):
|
||||
_clear(monkeypatch)
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "inv.db"))
|
||||
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
|
||||
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
|
||||
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
|
||||
monkeypatch.setenv("AUTOPASS_SESSION_SECRET", "x" * 32)
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_mediu_test_nu_declanseaza_invarianta(monkeypatch):
|
||||
"""Default rar_env="test" -> no-op, indiferent de require_api_key/session_secret."""
|
||||
_clear(monkeypatch)
|
||||
tmp = tempfile.mkdtemp()
|
||||
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "inv2.db"))
|
||||
monkeypatch.setenv("AUTOPASS_LOG_DIR", os.path.join(tmp, "logs"))
|
||||
from app.config import get_settings
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
r = c.get("/healthz")
|
||||
assert r.status_code == 200
|
||||
get_settings.cache_clear()
|
||||
134
tools/backup_db.sh
Executable file
134
tools/backup_db.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup criptat SQLite pentru gateway RAR AUTOPASS (T2/P0-4, evidenta legala L.142/2023).
|
||||
#
|
||||
# Foloseste API-ul de backup online SQLite (sqlite3.Connection.backup, stdlib python3) —
|
||||
# NU `cp`/`rsync`: baza ruleaza in WAL, iar o copie simpla a fisierului .db poate prinde
|
||||
# tranzactii necheckpoint-ate (copie inconsistenta). python3 e ales ca prim mijloc (nu
|
||||
# binarul CLI `sqlite3`) pentru ca e garantat prezent in imaginea Docker si pe masina de
|
||||
# dezvoltare; binarul `sqlite3` e folosit doar daca python3 lipseste (caz neasteptat).
|
||||
#
|
||||
# Rezultatul e comprimat (gzip) si criptat simetric (gpg AES256) — snapshot-ul contine
|
||||
# PII criptat Fernet plus metadate, un backup NECRIPTAT nu e permis.
|
||||
#
|
||||
# Utilizare:
|
||||
# AUTOPASS_BACKUP_PASSPHRASE_FILE=/run/secrets/backup_pass tools/backup_db.sh
|
||||
# AUTOPASS_BACKUP_PASSPHRASE=parola-de-test tools/backup_db.sh
|
||||
#
|
||||
# Variabile de mediu:
|
||||
# AUTOPASS_DB_PATH cale baza sursa (default /data/autopass.db)
|
||||
# AUTOPASS_BACKUP_DIR director destinatie backup-uri (default /data/backups)
|
||||
# AUTOPASS_BACKUP_KEEP cate backup-uri locale pastreaza (default 14)
|
||||
# AUTOPASS_BACKUP_PASSPHRASE_FILE fisier cu parola de criptare (preferat)
|
||||
# AUTOPASS_BACKUP_PASSPHRASE parola de criptare inline (fallback, ex. teste)
|
||||
# AUTOPASS_BACKUP_RCLONE_REMOTE tinta rclone optionala (ex. remote:bucket/autopass)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_PATH="${AUTOPASS_DB_PATH:-/data/autopass.db}"
|
||||
BACKUP_DIR="${AUTOPASS_BACKUP_DIR:-/data/backups}"
|
||||
KEEP="${AUTOPASS_BACKUP_KEEP:-14}"
|
||||
RCLONE_REMOTE="${AUTOPASS_BACKUP_RCLONE_REMOTE:-}"
|
||||
|
||||
log() { echo "[backup_db] $*"; }
|
||||
err() { echo "[backup_db] EROARE: $*" >&2; }
|
||||
|
||||
CLEANUP_PATHS=()
|
||||
cleanup() {
|
||||
local p
|
||||
for p in "${CLEANUP_PATHS[@]:-}"; do
|
||||
[[ -n "$p" ]] && rm -rf "$p"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- parola de criptare: fara ea, refuzam sa scriem un backup necriptat ---
|
||||
if [[ -n "${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}" ]]; then
|
||||
if [[ ! -f "$AUTOPASS_BACKUP_PASSPHRASE_FILE" ]]; then
|
||||
err "AUTOPASS_BACKUP_PASSPHRASE_FILE indica un fisier inexistent: $AUTOPASS_BACKUP_PASSPHRASE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
PASSPHRASE_FILE="$AUTOPASS_BACKUP_PASSPHRASE_FILE"
|
||||
elif [[ -n "${AUTOPASS_BACKUP_PASSPHRASE:-}" ]]; then
|
||||
PASSPHRASE_FILE="$(mktemp)"
|
||||
chmod 600 "$PASSPHRASE_FILE"
|
||||
CLEANUP_PATHS+=("$PASSPHRASE_FILE")
|
||||
printf '%s' "$AUTOPASS_BACKUP_PASSPHRASE" > "$PASSPHRASE_FILE"
|
||||
else
|
||||
err "lipseste parola de criptare a backup-ului."
|
||||
err "seteaza AUTOPASS_BACKUP_PASSPHRASE_FILE (recomandat) sau AUTOPASS_BACKUP_PASSPHRASE."
|
||||
err "backup necriptat NU este permis (contine PII criptat Fernet si metadate)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$DB_PATH" ]]; then
|
||||
err "baza de date nu exista: $DB_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
STAMP="$(date -u +%Y%m%d-%H%M%S)"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
CLEANUP_PATHS+=("$TMP_DIR")
|
||||
SNAPSHOT="$TMP_DIR/autopass-$STAMP.db"
|
||||
|
||||
log "snapshot online din $DB_PATH"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$DB_PATH" "$SNAPSHOT" <<'PYEOF'
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
src = sqlite3.connect(sys.argv[1])
|
||||
dst = sqlite3.connect(sys.argv[2])
|
||||
try:
|
||||
with dst:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
src.close()
|
||||
dst.close()
|
||||
PYEOF
|
||||
elif command -v sqlite3 >/dev/null 2>&1; then
|
||||
sqlite3 "$DB_PATH" ".backup '$SNAPSHOT'"
|
||||
else
|
||||
err "nici python3, nici binarul sqlite3 nu sunt disponibile - nu pot face backup online"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -s "$SNAPSHOT" ]]; then
|
||||
err "snapshot-ul rezultat e gol sau lipseste: $SNAPSHOT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "comprim (gzip)"
|
||||
gzip -9 "$SNAPSHOT"
|
||||
SNAPSHOT_GZ="$SNAPSHOT.gz"
|
||||
|
||||
FINAL="$BACKUP_DIR/autopass-$STAMP.db.gz.gpg"
|
||||
log "criptez simetric (gpg AES256) -> $FINAL"
|
||||
gpg --batch --yes --symmetric --cipher-algo AES256 \
|
||||
--passphrase-file "$PASSPHRASE_FILE" \
|
||||
--output "$FINAL" \
|
||||
"$SNAPSHOT_GZ"
|
||||
|
||||
log "backup scris: $FINAL ($(du -h "$FINAL" | cut -f1))"
|
||||
|
||||
# --- retentie locala: pastreaza ultimele $KEEP, sterge restul ---
|
||||
mapfile -t OLD_BACKUPS < <(ls -1t "$BACKUP_DIR"/autopass-*.db.gz.gpg 2>/dev/null | tail -n "+$((KEEP + 1))")
|
||||
if [[ "${#OLD_BACKUPS[@]}" -gt 0 ]]; then
|
||||
log "sterg ${#OLD_BACKUPS[@]} backup(uri) vechi (pastrez ultimele $KEEP)"
|
||||
rm -f "${OLD_BACKUPS[@]}"
|
||||
fi
|
||||
|
||||
# --- upload optional remote ---
|
||||
if [[ -n "$RCLONE_REMOTE" ]]; then
|
||||
if command -v rclone >/dev/null 2>&1; then
|
||||
log "upload rclone -> $RCLONE_REMOTE"
|
||||
rclone copy "$FINAL" "$RCLONE_REMOTE"
|
||||
else
|
||||
log "AUTOPASS_BACKUP_RCLONE_REMOTE setat dar rclone nu e instalat - sar peste upload remote"
|
||||
fi
|
||||
else
|
||||
log "AUTOPASS_BACKUP_RCLONE_REMOTE nesetat - sar peste upload remote (doar local)"
|
||||
fi
|
||||
|
||||
log "gata."
|
||||
112
tools/restore_check.sh
Executable file
112
tools/restore_check.sh
Executable file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verificare de restaurare pentru backup-urile SQLite criptate (T2/P0-4).
|
||||
#
|
||||
# Un backup neverificat nu e backup: decripteaza + dezarhiveaza (intr-un fisier temporar,
|
||||
# NU suprascrie baza vie) ultimul backup (sau cel indicat explicit) si ruleaza
|
||||
# PRAGMA integrity_check + un SELECT count(*) FROM submissions. Iese cu cod != 0 daca
|
||||
# ceva esueaza sau daca integritatea nu e "ok".
|
||||
#
|
||||
# Utilizare:
|
||||
# AUTOPASS_BACKUP_PASSPHRASE_FILE=/run/secrets/backup_pass tools/restore_check.sh
|
||||
# tools/restore_check.sh /data/backups/autopass-20260706-120000.db.gz.gpg
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="${AUTOPASS_BACKUP_DIR:-/data/backups}"
|
||||
|
||||
log() { echo "[restore_check] $*"; }
|
||||
err() { echo "[restore_check] EROARE: $*" >&2; }
|
||||
|
||||
CLEANUP_PATHS=()
|
||||
cleanup() {
|
||||
local p
|
||||
for p in "${CLEANUP_PATHS[@]:-}"; do
|
||||
[[ -n "$p" ]] && rm -rf "$p"
|
||||
done
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -n "${AUTOPASS_BACKUP_PASSPHRASE_FILE:-}" ]]; then
|
||||
if [[ ! -f "$AUTOPASS_BACKUP_PASSPHRASE_FILE" ]]; then
|
||||
err "AUTOPASS_BACKUP_PASSPHRASE_FILE indica un fisier inexistent: $AUTOPASS_BACKUP_PASSPHRASE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
PASSPHRASE_FILE="$AUTOPASS_BACKUP_PASSPHRASE_FILE"
|
||||
elif [[ -n "${AUTOPASS_BACKUP_PASSPHRASE:-}" ]]; then
|
||||
PASSPHRASE_FILE="$(mktemp)"
|
||||
chmod 600 "$PASSPHRASE_FILE"
|
||||
CLEANUP_PATHS+=("$PASSPHRASE_FILE")
|
||||
printf '%s' "$AUTOPASS_BACKUP_PASSPHRASE" > "$PASSPHRASE_FILE"
|
||||
else
|
||||
err "lipseste parola de decriptare."
|
||||
err "seteaza AUTOPASS_BACKUP_PASSPHRASE_FILE (recomandat) sau AUTOPASS_BACKUP_PASSPHRASE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" != "" ]]; then
|
||||
TARGET="$1"
|
||||
else
|
||||
TARGET="$(ls -1t "$BACKUP_DIR"/autopass-*.db.gz.gpg 2>/dev/null | head -n1 || true)"
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET" || ! -f "$TARGET" ]]; then
|
||||
err "niciun backup gasit de verificat (director: $BACKUP_DIR)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "verific: $TARGET"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
CLEANUP_PATHS+=("$TMP_DIR")
|
||||
GZ_FILE="$TMP_DIR/snapshot.db.gz"
|
||||
DB_FILE="$TMP_DIR/snapshot.db"
|
||||
|
||||
log "decriptez (gpg)"
|
||||
gpg --batch --yes --decrypt --passphrase-file "$PASSPHRASE_FILE" \
|
||||
--output "$GZ_FILE" "$TARGET"
|
||||
|
||||
log "dezarhivez (gzip)"
|
||||
gzip -d "$GZ_FILE"
|
||||
|
||||
if [[ ! -s "$DB_FILE" ]]; then
|
||||
err "fisierul restaurat e gol sau lipseste: $DB_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "PRAGMA integrity_check + count(submissions)"
|
||||
set +e
|
||||
RESULT="$(python3 - "$DB_FILE" <<'PYEOF'
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
conn = sqlite3.connect(sys.argv[1])
|
||||
try:
|
||||
integrity = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
try:
|
||||
count = conn.execute("SELECT count(*) FROM submissions").fetchone()[0]
|
||||
except sqlite3.OperationalError as exc:
|
||||
print(f"integrity={integrity}")
|
||||
print(f"eroare_count_submissions={exc}")
|
||||
sys.exit(1)
|
||||
print(f"integrity={integrity}")
|
||||
print(f"count_submissions={count}")
|
||||
finally:
|
||||
conn.close()
|
||||
PYEOF
|
||||
)"
|
||||
STATUS=$?
|
||||
set -e
|
||||
|
||||
echo "$RESULT"
|
||||
|
||||
if [[ $STATUS -ne 0 ]]; then
|
||||
err "verificare esuata (vezi mesajul de mai sus)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "^integrity=ok$" <<<"$RESULT"; then
|
||||
err "PRAGMA integrity_check NU a raportat 'ok'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "backup valid: restaurabil, integritate ok."
|
||||
Reference in New Issue
Block a user