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 ---
|
# --- Auth API-key ---
|
||||||
# true = orice /v1/* cere cheie valida (prod). false = dev (fara cheie -> cont id=1).
|
# 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
|
AUTOPASS_REQUIRE_API_KEY=false
|
||||||
|
|
||||||
# --- Worker ---
|
# --- Worker ---
|
||||||
# Send catre RAR. false = nu trimite (default, sigur pentru probe). true = end-to-end.
|
# 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
|
AUTOPASS_WORKER_SEND_ENABLED=false
|
||||||
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
# Dev: foloseste creds <test> din settings.xml cand submission-ul nu are creds criptate.
|
||||||
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
AUTOPASS_WORKER_USE_TEST_CREDS=false
|
||||||
|
|
||||||
# --- RAR ---
|
# --- 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
|
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) ---
|
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||||
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
# false = dezactivat (default; /mapari instant, sugestii din GOLD/SILVER + fuzzy).
|
||||||
# true = sugestii semantice. Prima cerere /mapari lazy-load-eaza modelul fastembed/ONNX
|
# 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.
|
# (~230MB pe disc) sincron -> hang la prima cerere. Doar API-ul il incarca.
|
||||||
AUTOPASS_EMBEDDINGS_ENABLED=false
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
# tzdata = necesar pentru ca 'localtime' din SQLite sa rezolve Europe/Bucharest.
|
# 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/*
|
&& 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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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.
|
# Date persistente (SQLite WAL) pe volum montat.
|
||||||
ENV AUTOPASS_DB_PATH=/data/autopass.db
|
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"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
EXPOSE 8000
|
USER app
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
# Default = API. Worker-ul suprascrie command in compose.
|
# 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.
|
# conftest il dezactiveaza global, testele care-l vor il pornesc punctual.
|
||||||
seed_operatii_enabled: bool = True
|
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
|
@property
|
||||||
def rar_base_url(self) -> str:
|
def rar_base_url(self) -> str:
|
||||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
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()
|
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:
|
def load_test_credentials(settings_xml: Path | None = None) -> dict | None:
|
||||||
"""Citeste credentialele <test> din settings.xml (dev local / probe test).
|
"""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 email.message import EmailMessage
|
||||||
|
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
|
from .observ import log_event
|
||||||
|
|
||||||
|
|
||||||
def notify_signup(admin_emails: list[str], account_id: int, email: str) -> None:
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
if not settings.smtp_host or not admin_emails:
|
if not settings.smtp_host or not admin_emails:
|
||||||
print(
|
log_event(
|
||||||
f"SIGNUP-NOTIFY degradat (fara SMTP) cont={account_id} "
|
"signup_notify_degradat",
|
||||||
f"email={email} admins={len(admin_emails)}",
|
account_id=account_id,
|
||||||
flush=True,
|
mesaj="fara SMTP configurat",
|
||||||
|
context={"admins": len(admin_emails)},
|
||||||
)
|
)
|
||||||
return
|
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.import_router import router as import_v1_router
|
||||||
from .api.v1.integrare_router import router as integrare_v1_router
|
from .api.v1.integrare_router import router as integrare_v1_router
|
||||||
from .api.v1.router import router as api_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 .crypto import validate_creds_key
|
||||||
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
from .db import get_connection, init_db, queue_depth, read_heartbeat
|
||||||
from .observ import log_event, request_id_var
|
from .observ import log_event, request_id_var
|
||||||
from .security import install_log_redaction, scrub_text
|
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.routes import router as web_router
|
||||||
from .web.auth_routes import router as auth_router
|
from .web.auth_routes import router as auth_router
|
||||||
from .web.admin_routes import router as admin_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,
|
# Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar,
|
||||||
# in loc de 500 brut la primul POST /v1/prezentari.
|
# in loc de 500 brut la primul POST /v1/prezentari.
|
||||||
validate_creds_key()
|
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()
|
init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -63,6 +67,12 @@ app.add_middleware(
|
|||||||
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
# OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile,
|
||||||
# inclusiv 401/404/422/500 produse mai in interior.
|
# inclusiv 401/404/422/500 produse mai in interior.
|
||||||
app.add_middleware(RequestIDMiddleware)
|
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)
|
@app.exception_handler(LoginRequired)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from ..auth import create_api_key
|
|||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import get_connection
|
from ..db import get_connection
|
||||||
from ..email import notify_signup
|
from ..email import notify_signup
|
||||||
|
from ..observ import log_event
|
||||||
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
from ..users import count_admins, create_user, list_admin_emails, verify_password
|
||||||
from ..web.csrf import get_csrf_token, verify_csrf
|
from ..web.csrf import get_csrf_token, verify_csrf
|
||||||
from ..web.ratelimit import check_rate_limit
|
from ..web.ratelimit import check_rate_limit
|
||||||
@@ -157,7 +158,7 @@ async def signup_post(
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
set_session(request, account_id, user_id)
|
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)
|
# Notificare email admin (best-effort, nu blocheaza signup-ul)
|
||||||
try:
|
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)
|
request_id_var.reset(token)
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
return response
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
# ip/key -> lista de timestamps (time.monotonic) ale cererilor din fereastra activa
|
# 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:
|
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
|
Curata timestamp-urile expirate la fiecare apel (O(n) per cheie, acceptabil
|
||||||
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
pentru trafic de signup). Thread-safety: GIL Python protejeaza list ops simple;
|
||||||
suficient pentru un singur proces uvicorn.
|
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()
|
now = time.monotonic()
|
||||||
cutoff = now - window_s
|
cutoff = now - window_s
|
||||||
timestamps = _hits[key]
|
filtered = [t for t in _hits.get(key, []) if t > cutoff]
|
||||||
# Sterge intrari expirate
|
if len(filtered) >= max_hits:
|
||||||
_hits[key] = [t for t in timestamps if t > cutoff]
|
if filtered:
|
||||||
if len(_hits[key]) >= max_hits:
|
_hits[key] = filtered
|
||||||
|
else:
|
||||||
|
_hits.pop(key, None)
|
||||||
return False
|
return False
|
||||||
_hits[key].append(now)
|
filtered.append(now)
|
||||||
|
_hits[key] = filtered
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -13,15 +13,35 @@ services:
|
|||||||
- autopass-data:/data
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
AUTOPASS_DB_PATH: /data/autopass.db
|
||||||
# Override din environment (Dokploy) pentru staging; default = prod.
|
# Ancora globala, NU tinta trimiterilor: dupa PRD 5.20 mediul RAR e per
|
||||||
AUTOPASS_RAR_ENV: ${AUTOPASS_RAR_ENV:-prod}
|
# 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).
|
# Fus orar RO pentru bucketarea contoarelor azi/luna (SQLite 'localtime', E7).
|
||||||
TZ: ${TZ:-Europe/Bucharest}
|
TZ: ${TZ:-Europe/Bucharest}
|
||||||
AUTOPASS_CREDS_KEY: ${AUTOPASS_CREDS_KEY:?seteaza AUTOPASS_CREDS_KEY in .env (vezi .env.example)}
|
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
|
# Embeddings (sugestie mapare, Stratul 2): prima cerere /mapari lazy-load-eaza
|
||||||
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
# modelul ~230MB. Doar API-ul il incarca (worker-ul nu). Default off.
|
||||||
AUTOPASS_EMBEDDINGS_ENABLED: ${AUTOPASS_EMBEDDINGS_ENABLED:-false}
|
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
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8010/healthz').status==200 else 1)"]
|
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
|
- autopass-data:/data
|
||||||
environment:
|
environment:
|
||||||
AUTOPASS_DB_PATH: /data/autopass.db
|
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)}
|
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
|
# Send catre RAR. Obligatoriu explicit — fara ea, un misconfig ar putea lasa
|
||||||
# in Dokploy ca worker-ul sa NU trimita declaratii reale la RAR (Legea 142/2023).
|
# worker-ul sa trimita declaratii reale neintentionat (Legea 142/2023).
|
||||||
AUTOPASS_WORKER_SEND_ENABLED: ${AUTOPASS_WORKER_SEND_ENABLED:-true}
|
# 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
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
@@ -68,5 +92,33 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /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:
|
volumes:
|
||||||
autopass-data:
|
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_DB_PATH", os.path.join(tmp, "t.db"))
|
||||||
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
|
monkeypatch.setenv("AUTOPASS_REQUIRE_API_KEY", "true")
|
||||||
monkeypatch.setenv("AUTOPASS_RAR_ENV", "prod")
|
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
|
from app.config import get_settings
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
from app.main import app
|
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
|
# Teste C16 (log SIGNUP pastrat) si best-effort E2E
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_signup_inca_logheaza_si_notifica(client, capsys):
|
def test_signup_logheaza_fara_pii_in_stdout(client, capsys):
|
||||||
"""Signup reusit -> stdout contine 'SIGNUP cont=' (C16 pastrat)."""
|
"""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")
|
resp = _do_signup(client, "Service Log Test", "log@test.com")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "rfak_" in resp.text
|
assert "rfak_" in resp.text
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "SIGNUP cont=" in captured.out, (
|
assert "log@test.com" not in captured.out, (
|
||||||
f"Linia de log C16 'SIGNUP cont=' lipseste din stdout. Capturat: {captured.out!r}"
|
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):
|
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