Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cb1e21a
fix(deploy): KRİTİK prod şema migration-only + güvenlik altyapısı (YÜ…
Ohualtex Jun 9, 2026
9e7818e
fix(security): frontend stored/reflected XSS sertleştirme (YÜKSEK #4/…
Ohualtex Jun 9, 2026
b11fb3e
fix(validation): max_length + delete cascade + area_hectares (YÜKSEK …
Ohualtex Jun 9, 2026
a4612aa
fix(api): weather response wind_speed_kmh ekle (ORTA #11)
Ohualtex Jun 9, 2026
efc8e0c
fix(security): leaky/unauth endpoint'leri yetkilendir + prod'da /docs…
Ohualtex Jun 9, 2026
728e20e
fix(logic): analitik/şema/servis edge-case'leri (ORTA #4/#5/#9/#10/#1…
Ohualtex Jun 9, 2026
506514d
fix(data): email normalize + alert field-ownership + role NOT NULL mi…
Ohualtex Jun 9, 2026
460297a
fix(ui): frontend UX + i18n + hata mesajı (ORTA #23/#24/#25/#26/#27/#28)
Ohualtex Jun 9, 2026
215602f
fix(infra): .dockerignore + compose bind-mount + requirements pin (OR…
Ohualtex Jun 9, 2026
9ac2346
fix(auth): register/login sertleştirme + rol-PATCH testi (DÜŞÜK L1-L5)
Ohualtex Jun 9, 2026
9311f26
fix(backend): router/servis edge-case'leri (DÜŞÜK L6-L22)
Ohualtex Jun 9, 2026
cde8569
fix(schemas): response Optional + irrigation source + yorum (DÜŞÜK L1…
Ohualtex Jun 9, 2026
4dddd7a
fix(security): CORS exact-host + HSTS dedup + error yanıtlarda güvenl…
Ohualtex Jun 9, 2026
7989d00
fix(ui): frontend düşük-öncelik cilalar (DÜŞÜK L10/L16-L18/L25-L32)
Ohualtex Jun 9, 2026
7a98719
fix(analytics): in-memory aggregation → SQL func (ORTA #22)
Ohualtex Jun 9, 2026
7f24937
fix(ml): irrigation confidence model-varyansından türetiliyor (ORTA #20)
Ohualtex Jun 9, 2026
213eaa8
fix(db): PostgreSQL oturum UTC TZ + paho/onnx pin (DÜŞÜK L7 + ORTA #3)
Ohualtex Jun 9, 2026
3740c10
fix(welcome): filiz SVG'sini site-içi maskotla eşitle (kozmetik)
Ohualtex Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Audit fix: Dockerfile "COPY . ." dev sqlite DB / logs / .git / tests / .env
# secret'lerini prod image'e gomuyordu. Runtime'da gereksiz ve hassas dosyalari
# image disinda tutmak icin eklendi.
# Calisma zamaninda gereken sey context'te kalir: app/, frontend/, alembic/,
# requirements.txt, alembic.ini, docker-entrypoint.sh

# Version control
.git
.github

# Veritabani dosyalari (dev sqlite vs.)
*.db
*.sqlite*

# Secrets / ortam
.env

# Sanal ortam ve Python cache
.venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
htmlcov

# Loglar
logs/

# Frontend bagimliliklari
node_modules

# Test ve dokuman dosyalari (runtime'da gereksiz)
tests/
docs/
*.md
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ DB_POOL_RECYCLE=3600
# Container/prod icin 0.0.0.0; local dev icin 127.0.0.1
API_HOST=127.0.0.1
API_PORT=8000
API_DEBUG=True
# Prod'da MUTLAKA False: True iken SQLAlchemy echo SQL+parametreleri (sifre hash/PII) loglar.
API_DEBUG=False

# ─── Kimlik dogrulama (PROD'DA ZORUNLU OVERRIDE) ──────────
# Yeni anahtar uretmek icin: python -c "import secrets; print(secrets.token_urlsafe(32))"
Expand Down
7 changes: 5 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ RUN pip install --no-cache /wheels/*
# Copy application code
COPY . .

# Entrypoint'i çalıştırılabilir yap (prod'da `alembic upgrade head` → uvicorn)
RUN chmod +x /app/docker-entrypoint.sh

# Expose port
EXPOSE 8000

# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application (prod: migrate-then-serve; dev: create_all main.py'de)
CMD ["/app/docker-entrypoint.sh"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""rbac role NOT NULL drift fix (audit ORTA 18)

Revision ID: c5eee337bec4
Revises: c2d3e4f5a6b7
Create Date: 2026-06-09 13:58:12.980002

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "c5eee337bec4"
down_revision: str | Sequence[str] | None = "c2d3e4f5a6b7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""users.role NOT NULL — model (nullable=False) ile hizala.

İlk migration role'ü nullable kurmuştu (audit ORTA #18: model/migration
drift; CHECK constraint NULL'ı reddetmiyordu). Önce NULL/boş role'leri
'farmer'a backfill et, sonra kolonu NOT NULL yap. PostgreSQL'de native
ALTER; SQLite'ta batch tablo rebuild (mevcut CHECK + index korunur).
"""
op.execute("UPDATE users SET role = 'farmer' WHERE role IS NULL OR role = ''")
with op.batch_alter_table("users") as batch_op:
batch_op.alter_column("role", existing_type=sa.String(length=20), nullable=False)


def downgrade() -> None:
"""role'ü tekrar nullable yap (drift'in eski hâline dönüş)."""
with op.batch_alter_table("users") as batch_op:
batch_op.alter_column("role", existing_type=sa.String(length=20), nullable=True)
33 changes: 31 additions & 2 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@
from __future__ import annotations

import warnings
from urllib.parse import urlsplit

from pydantic import ConfigDict, model_validator
from pydantic_settings import BaseSettings

# Loopback/local host adları — production CORS allowlist'inde yasak.
# urlsplit().hostname IPv6 köşeli parantezleri soyduğu için '::1' ham olarak karşılaştırılır.
_LOCAL_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})

# Local-only sentinel'ler. Production'da bunlar görüldüğünde uygulama başlatılmaz.
_DEV_API_KEY = "dev-api-key" # noqa: S105 — sentinel, gerçek secret değil
_DEV_SECRET_KEY = "dev-secret-key" # noqa: S105 — sentinel, gerçek secret değil
Expand Down Expand Up @@ -49,7 +54,9 @@ class Settings(BaseSettings):
# Default: localhost. Container/prod için env üzerinden 0.0.0.0 verilebilir.
API_HOST: str = "127.0.0.1"
API_PORT: int = 8000
API_DEBUG: bool = True
# echo'yu sürer → prod'da tüm SQL + parametreler (şifre hash/PII) log'a sızar
# (audit YÜKSEK). Güvenli default = False; dev'de .env ile True yapılabilir.
API_DEBUG: bool = False
API_TITLE: str = "SFDAP - Akilli Tarim Veri Analizi Platformu API"
API_VERSION: str = "1.0.0"

Expand Down Expand Up @@ -124,7 +131,10 @@ def _validate_production(self) -> Settings:
)
# CORS allowlist hijack defense: production'da `*` veya local
# origin'ler attack surface açar (CSRF, credential exfil).
insecure_origins = [o for o in self.cors_origins_list if o == "*" or "localhost" in o or "127.0.0.1" in o]
# Substring eşleşmesi yerine her origin'i parse edip HOSTNAME'i tam
# karşılaştır: 'app.localhost.example.com' gibi meşru domain'ler
# yanlışlıkla bloklanmasın, IPv6 loopback ('[::1]') de kaçmasın.
insecure_origins = [o for o in self.cors_origins_list if o == "*" or self._is_local_origin(o)]
if insecure_origins:
raise RuntimeError(
f"ENVIRONMENT=production iken CORS_ORIGINS guvensiz origin'ler "
Expand All @@ -136,8 +146,27 @@ def _validate_production(self) -> Settings:
"ENVIRONMENT=production ama API_HOST=127.0.0.1; container icinde 0.0.0.0 olmali.",
stacklevel=2,
)
if self.API_DEBUG:
warnings.warn(
"ENVIRONMENT=production ama API_DEBUG=True; SQLAlchemy echo SQL+parametreleri "
"(sifre hash/PII) log'a yazar. .env'de API_DEBUG=False yapin "
"(echo yine de prod'da zorla kapatilir, bkz. database.py).",
stacklevel=2,
)
return self

@staticmethod
def _is_local_origin(origin: str) -> bool:
"""Origin'in loopback/localhost'a işaret edip etmediğini hostname bazında belirle.

Substring kontrolü ('localhost' in o) yanlış pozitif üretir
('app.localhost.example.com' meşru bir domain'dir) ve IPv6 loopback'i
('[::1]') kaçırır. urlsplit ile host kısmını ayıklayıp tam eşleştiriyoruz.
urlsplit, IPv6 host'un köşeli parantezlerini soyar → '::1' ile karşılaştırılır.
"""
host = urlsplit(origin).hostname
return host is not None and host.lower() in _LOCAL_HOSTNAMES

@property
def cors_origins_list(self) -> list[str]:
"""CORS_ORIGINS env string'ini liste olarak döndür."""
Expand Down
11 changes: 10 additions & 1 deletion app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ def _build_engine_kwargs() -> dict:
multi-thread arg, server DBs get pool tuning.
"""
is_sqlite = settings.DATABASE_URL.startswith("sqlite")
kwargs: dict = {"echo": settings.API_DEBUG}
# echo prod'da ZORLA kapalı: SQLAlchemy echo tüm SQL + parametreleri (şifre
# hash, PII) log'a yazar. API_DEBUG yanlışlıkla True bırakılsa bile prod'da
# sızıntı olmaz (audit YÜKSEK).
kwargs: dict = {"echo": settings.API_DEBUG and settings.ENVIRONMENT != "production"}

if is_sqlite:
kwargs["connect_args"] = {"check_same_thread": False}
Expand All @@ -57,6 +60,12 @@ def _build_engine_kwargs() -> dict:
kwargs["max_overflow"] = settings.DB_MAX_OVERFLOW
kwargs["pool_pre_ping"] = settings.DB_POOL_PRE_PING
kwargs["pool_recycle"] = settings.DB_POOL_RECYCLE
# AUDIT L7: PostgreSQL oturum saat dilimini UTC'ye sabitle. Modelde naive
# DateTime kolonları (TIMESTAMP WITHOUT TIME ZONE) var; filtreler ise UTC-aware
# (datetime.now(UTC)). Oturum TZ'i UTC olmayınca karşılaştırma sunucu saatine
# göre kayabilir → UTC'ye sabitlemek naive-kolon/aware-filtre tutarlılığını
# garanti eder (timestamptz kolonlara geçiş = ayrı migration, follow-up).
kwargs["connect_args"] = {"options": "-c timezone=utc"}
return kwargs


Expand Down
15 changes: 12 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
setup_logging()
# Sentry — active when SENTRY_DSN env is set, otherwise no-op.
init_sentry()
init_db()
# Prod'da şema YALNIZ alembic migration'larıyla kurulur — create_all DEĞİL.
# create_all alembic_version'ı stamp'lemez, RBAC CHECK constraint'i + FK
# index'leri atlar ve belgelenen `alembic upgrade head` adımını DuplicateTable
# ile bozar (audit KRİTİK). Dev/test'te hızlı şema için create_all yeterli;
# production'da docker-entrypoint.sh `alembic upgrade head` çalıştırır.
if settings.ENVIRONMENT != "production":
init_db()
start_scheduler()
if settings.MQTT_ENABLED:
mqtt_listener.start()
Expand Down Expand Up @@ -197,8 +203,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
**SFDAP**, 5 kişilik öğrenci ekibi tarafından geliştirilen Scrum tabanlı bir projedir.
Çiftçi-odaklı bir saha aracıdır; admin/gözetmen rolleri sistem-geneli gözetim sağlar.
""",
docs_url="/docs",
redoc_url="/redoc",
# AUDIT FIX (#8): production'da Swagger/ReDoc/OpenAPI şemasını kapat — uç
# listesi + auth pattern'leri sızdırmasın. Dev/staging'de açık kalır.
docs_url=None if settings.ENVIRONMENT == "production" else "/docs",
redoc_url=None if settings.ENVIRONMENT == "production" else "/redoc",
openapi_url=None if settings.ENVIRONMENT == "production" else "/openapi.json",
openapi_tags=TAGS_METADATA,
lifespan=lifespan,
)
Expand Down
40 changes: 33 additions & 7 deletions app/middleware/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import IntegrityError

from app.config import settings
from app.middleware.security_headers import apply_security_headers

# ─── CUSTOM EXCEPTION SINIFLARI ─────────────────────────────────


Expand Down Expand Up @@ -125,14 +129,19 @@ async def sfdap_exception_handler(request: Request, exc: SFDAPError):
# detail None ise message'i fallback olarak koy (HTTPException uyumu)
detail = exc.detail if exc.detail is not None else exc.message

return JSONResponse(
response = JSONResponse(
status_code=status_code,
content={
"error_code": error_code,
"message": exc.message,
"detail": detail,
},
)
# status>=500 yanıtları SecurityHeadersMiddleware'i baypas edip dışarıdaki
# ServerErrorMiddleware'den döndüğü için güvenlik header'larını burada da
# garanti ediyoruz (setdefault → idempotent, baypas etmeyen yollarda no-op).
apply_security_headers(response.headers)
return response

@app.exception_handler(IntegrityError)
async def integrity_exception_handler(request: Request, exc: IntegrityError):
Expand All @@ -146,14 +155,20 @@ async def integrity_exception_handler(request: Request, exc: IntegrityError):
UNIQUE/FK ihlallerini 409 Conflict olarak normalize eder; aksi
halde 500 olarak yansır ve client hatası gibi davranmaz.
"""
return JSONResponse(
# Ham DB hata metni (str(exc.orig)) tablo/kolon/constraint adlarını —
# bazen değerleri — sızdırır → prod'da gizle, sunucuda logla (audit YÜKSEK).
raw = str(exc.orig) if exc.orig else str(exc)
logger.warning(f"IntegrityError: {raw}")
response = JSONResponse(
status_code=409,
content={
"error_code": "CONFLICT",
"message": "Veri çakışması: kayıt zaten mevcut veya ilişki kuralı ihlal edildi.",
"detail": str(exc.orig) if exc.orig else str(exc),
"detail": raw if settings.ENVIRONMENT != "production" else None,
},
)
apply_security_headers(response.headers)
return response

@app.exception_handler(RequestValidationError)
async def request_validation_handler(request: Request, exc: RequestValidationError):
Expand All @@ -165,7 +180,7 @@ async def request_validation_handler(request: Request, exc: RequestValidationErr
yollarında kullanılır (validation İngilizce kalır; frontend toast tarafı
message map'leme yapar).
"""
return JSONResponse(
response = JSONResponse(
status_code=422,
content={
"detail": [
Expand All @@ -178,15 +193,26 @@ async def request_validation_handler(request: Request, exc: RequestValidationErr
],
},
)
apply_security_headers(response.headers)
return response

@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Beklenmeyen hataları yakalar ve tutarlı format döndürür."""
return JSONResponse(
"""Beklenmeyen hataları yakalar ve tutarlı format döndürür.

Ham exception metni (str(exc)) path/SQL/secret sızdırabilir → prod'da
client'a dönmez, tam traceback sunucuda loglanır (audit YÜKSEK).
"""
logger.exception(f"Beklenmeyen hata: {exc}")
response = JSONResponse(
status_code=500,
content={
"error_code": "INTERNAL_ERROR",
"message": "Beklenmeyen bir sunucu hatası oluştu.",
"detail": str(exc) if exc.args else None,
"detail": (str(exc) if exc.args else None) if settings.ENVIRONMENT != "production" else None,
},
)
# 500 yanıtı dıştaki ServerErrorMiddleware'den döner →
# SecurityHeadersMiddleware'i baypas eder; header'ları burada enjekte et.
apply_security_headers(response.headers)
return response
50 changes: 29 additions & 21 deletions app/middleware/security_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
Production response header guarantee for the FastAPI app:

Content-Security-Policy XSS / data exfiltration defense
Strict-Transport-Security Force HTTPS (production only)
X-Frame-Options: DENY Clickjacking defense
X-Content-Type-Options: nosniff MIME sniffing defense
Referrer-Policy PII leak via Referer header
Expand All @@ -23,8 +22,9 @@
de data-action'a çevrildiğinde CSP'den `'unsafe-inline'` drop
edilebilir.

- HSTS sadece `ENVIRONMENT=production` iken eklenir; dev/HTTPS olmayan
setup'larda eklenmesi browser'da preload yanlış pin'leyebilir.
- HSTS (Strict-Transport-Security) BURADA EKLENMEZ: TLS'i nginx terminate
ettiği için header'ın tek sahibi nginx'tir (nginx/conf.d/default.conf.template).
App katmanında eklemek çakışan/duplike header üretiyordu.

- `/metrics` endpoint'i `include_in_schema=False` ve robotlardan uzak
durmalı; X-Robots-Tag: noindex bunu sağlar.
Expand All @@ -42,8 +42,6 @@
from starlette.requests import Request
from starlette.responses import Response

from app.config import settings

# Sabit header değerleri — modül seviyesi, request başına yeniden hesaplanmasın.

# CSP: virgülle değil, ;'le ayrılmış direktif listesi.
Expand Down Expand Up @@ -76,27 +74,37 @@
)


def apply_security_headers(headers) -> None: # noqa: ANN001 — starlette Headers (mutable mapping)
"""Defense-in-depth response header'larını verilen header map'ine yaz.

`setdefault` kullanır → zaten set edilmiş header'lar ezilmez (idempotent).
Hem `SecurityHeadersMiddleware` hem de exception handler'ları (bkz.
exceptions.py) bu tek kaynağı çağırır; böylece middleware'i baypas eden
500 yanıtlarında bile aynı header seti garanti edilir. HSTS burada YOK —
onun tek sahibi nginx (bkz. nginx/conf.d/default.conf.template).
"""
# CSP — XSS + data exfiltration.
headers.setdefault("Content-Security-Policy", CSP_HEADER)
# Clickjacking.
headers.setdefault("X-Frame-Options", "DENY")
# MIME sniffing.
headers.setdefault("X-Content-Type-Options", "nosniff")
# Referrer leakage.
headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Browser API allowlist (deny by default for unused).
headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)


class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Inject defense-in-depth response headers on every response."""

async def dispatch(self, request: Request, call_next) -> Response: # noqa: ANN001
response: Response = await call_next(request)
# CSP — XSS + data exfiltration.
response.headers.setdefault("Content-Security-Policy", CSP_HEADER)
# Clickjacking.
response.headers.setdefault("X-Frame-Options", "DENY")
# MIME sniffing.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Referrer leakage.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Browser API allowlist (deny by default for unused).
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
# HSTS — only in production (assumes HTTPS in front).
if settings.ENVIRONMENT == "production":
response.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
apply_security_headers(response.headers)
# HSTS — TLS'i nginx terminate ettiği için Strict-Transport-Security
# tek otorite olarak nginx tarafında set edilir (nginx/conf.d/default.conf.template).
# App katmanında tekrar eklemek çakışan/duplike header üretiyordu → kaldırıldı.
# (App doğrudan HTTPS sunmadığından burada HSTS doğru pin'lenemez.)
# /metrics endpoint'i public-discoverable olmasın.
if request.url.path == "/metrics":
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow")
Expand Down
Loading
Loading