diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26d026e --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 8fafa40..504a792 100644 --- a/.env.example +++ b/.env.example @@ -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))" diff --git a/Dockerfile b/Dockerfile index 8a6b9ff..732fe86 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py b/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py new file mode 100644 index 0000000..e201f47 --- /dev/null +++ b/alembic/versions/c5eee337bec4_rbac_role_not_null_drift_fix_audit_orta_.py @@ -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) diff --git a/app/config.py b/app/config.py index 5f434b1..3dfef73 100644 --- a/app/config.py +++ b/app/config.py @@ -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 @@ -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" @@ -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 " @@ -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.""" diff --git a/app/database.py b/app/database.py index d1f3cbb..f92e4a2 100644 --- a/app/database.py +++ b/app/database.py @@ -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} @@ -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 diff --git a/app/main.py b/app/main.py index 5f25623..37b94f5 100644 --- a/app/main.py +++ b/app/main.py @@ -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() @@ -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, ) diff --git a/app/middleware/exceptions.py b/app/middleware/exceptions.py index 5308cdf..ab669a8 100644 --- a/app/middleware/exceptions.py +++ b/app/middleware/exceptions.py @@ -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 ───────────────────────────────── @@ -125,7 +129,7 @@ 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, @@ -133,6 +137,11 @@ async def sfdap_exception_handler(request: Request, exc: SFDAPError): "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): @@ -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): @@ -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": [ @@ -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 diff --git a/app/middleware/security_headers.py b/app/middleware/security_headers.py index 2a387b7..57c8b7f 100644 --- a/app/middleware/security_headers.py +++ b/app/middleware/security_headers.py @@ -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 @@ -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. @@ -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. @@ -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") diff --git a/app/ml/irrigation_model.py b/app/ml/irrigation_model.py index a1facca..a4a0025 100644 --- a/app/ml/irrigation_model.py +++ b/app/ml/irrigation_model.py @@ -38,7 +38,8 @@ class IrrigationOptimizer: OPTIMAL_MOISTURE_PERCENT: float = 50.0 # ideal toprak nemi CONFIDENCE_BASE: float = 0.7 CONFIDENCE_CAP: float = 0.95 - CONFIDENCE_MOISTURE_DIVISOR: float = 200.0 + # Ağaçlar arası tahmin std'sini (litre) confidence düşüşüne ölçekler (audit #20). + CONFIDENCE_SPREAD_DIVISOR: float = 100.0 # Synthetic training parametreleri N_TRAINING_SAMPLES: int = 1000 @@ -110,10 +111,15 @@ def predict( predicted_water = max(0, round(predicted_water, 2)) irrigation_needed = predicted_water > self.IRRIGATION_THRESHOLD_LITERS - confidence = min( - self.CONFIDENCE_CAP, - self.CONFIDENCE_BASE - + (abs(self.OPTIMAL_MOISTURE_PERCENT - soil_moisture) / self.CONFIDENCE_MOISTURE_DIVISOR), + # Confidence = RandomForest ağaçları arası tahmin UYUMUNDAN türetilir (audit #20). + # Eski formül |optimal-moisture|/200 idi: optimal'de en DÜŞÜK, uçlarda en yüksek + # confidence veriyordu (ters) ve modelin gerçek belirsizliğinden kopuktu. Şimdi + # ağaçlar hemfikirse (düşük std) model emin → CAP'e yakın, dağınıksa BASE'e iner. + tree_preds = np.array([est.predict(features_scaled)[0] for est in self.model.estimators_]) + spread = float(tree_preds.std()) + confidence = max( + self.CONFIDENCE_BASE, + min(self.CONFIDENCE_CAP, self.CONFIDENCE_CAP - spread / self.CONFIDENCE_SPREAD_DIVISOR), ) if not irrigation_needed: diff --git a/app/models/models.py b/app/models/models.py index 21ceea0..446e43e 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,6 +1,18 @@ from datetime import UTC, datetime -from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) from sqlalchemy.orm import relationship from app.database import Base @@ -22,6 +34,16 @@ class User(Base): """ORM model for `users` table.""" __tablename__ = "users" + # RBAC bütünlüğü: `role` yalnız 4 geçerli değerden biri olabilir. Alembic + # migration (b1c2d3e4f5a6) prod'da aynı CHECK'i kurar; model'de de tanımlı + # olması create_all yolunun (dev/test) prod ile AYNI constraint'i üretmesini + # sağlar (audit: iki şema yolu ayrışmasın). + __table_args__ = ( + CheckConstraint( + "role IN (" + ", ".join(f"'{_r}'" for _r in USER_ROLES) + ")", + name="ck_users_role_valid", + ), + ) id = Column(Integer, primary_key=True, index=True) name = Column(String(100), nullable=False) email = Column(String(150), unique=True, nullable=False, index=True) @@ -118,6 +140,9 @@ class SoilMoistureReading(Base): __tablename__ = "soil_moisture_readings" id = Column(Integer, primary_key=True, index=True) sensor_id = Column(Integer, ForeignKey("sensors.id"), nullable=False) + # Audit L7: naive DateTime; filtreler UTC-aware datetime kullanıyor. + # PostgreSQL'de doğruluk doğrulanmadı. DateTime(timezone=True)'a geçiş + # migration gerektirir → SKIP (davranış değişikliği yok). reading_timestamp = Column(DateTime, default=lambda: datetime.now(UTC), index=True) moisture_percent = Column(Float, nullable=False) depth_cm = Column(Float) @@ -158,6 +183,7 @@ class SensorReadingMonthlyAggregate(Base): soil_temperature_max = Column(Float) electrical_conductivity_avg = Column(Float) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). archived_at = Column(DateTime, default=lambda: datetime.now(UTC), nullable=False) __table_args__ = ( @@ -172,6 +198,7 @@ class WeatherData(Base): __tablename__ = "weather_data" id = Column(Integer, primary_key=True, index=True) farm_id = Column(Integer, ForeignKey("farms.id")) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). recorded_at = Column(DateTime, default=lambda: datetime.now(UTC), index=True) temperature_c = Column(Float) humidity_percent = Column(Float) @@ -203,6 +230,7 @@ class PlantHealthImage(Base): id = Column(Integer, primary_key=True, index=True) field_id = Column(Integer, ForeignKey("fields.id")) image_url = Column(String(500)) + # Audit L7: naive DateTime — bkz. SoilMoistureReading.reading_timestamp notu (SKIP, migration gerekir). captured_at = Column(DateTime, default=lambda: datetime.now(UTC)) diagnosis = Column(String(200)) confidence_score = Column(Float) diff --git a/app/routers/alerts.py b/app/routers/alerts.py index 6bf2a9a..6daf2b4 100644 --- a/app/routers/alerts.py +++ b/app/routers/alerts.py @@ -28,7 +28,7 @@ from app.database import MAX_SQLITE_INT, get_db from app.middleware.exceptions import ForbiddenError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter -from app.middleware.rbac import _BYPASS_ROLES, assert_farm_ownership, require_write +from app.middleware.rbac import _BYPASS_ROLES, assert_farm_ownership, assert_field_ownership, require_write from app.models.models import Farm, Field, PlantHealthImage, Sensor, SoilMoistureReading, SystemAlert, User from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import SystemAlertCreate, SystemAlertResponse, SystemAlertUpdate @@ -132,6 +132,10 @@ def create_alert( assert_farm_ownership(db, payload.farm_id, current_user) elif current_user.role == "farmer": raise ForbiddenError(detail="Farmer sistem-geneli uyarı (farm_id=None) oluşturamaz.") + # Audit fix: field_id verilmişse tarla sahipliği de doğrulanmalı — aksi + # halde alert sahibi olmadığın bir tarlayı referanslayabilir. + if payload.field_id is not None: + assert_field_ownership(db, payload.field_id, current_user) alert = SystemAlert(**payload.model_dump()) db.add(alert) db.commit() diff --git a/app/routers/analytics.py b/app/routers/analytics.py index e82df9c..b65c615 100644 --- a/app/routers/analytics.py +++ b/app/routers/analytics.py @@ -30,6 +30,7 @@ WeatherData, ) from app.routers.auth import require_role +from app.schemas.base import _serialize_utc from app.services.report_service import ReportService router = APIRouter(prefix="/api/analytics", tags=["Analitik & Görselleştirme"]) @@ -167,23 +168,30 @@ def get_analytics_summary( daily_trends.append(farm_trend) # ─── SENSÖR OKUMA İSTATİSTİKLERİ ────────────────────────────── - recent_readings = db.query(SoilMoistureReading).filter(SoilMoistureReading.reading_timestamp >= since).all() + # AUDIT #22: tüm okumaları belleğe çekmek yerine SQL aggregate (365 güne kadar, + # potansiyel on binlerce satır). AVG/MIN/MAX NULL'ları yok sayar (Python'daki + # non-null filtresiyle aynı sonuç); COUNT tüm satırları sayar. Değerler değişmez. + reading_agg = ( + db.query( + func.count(SoilMoistureReading.id), + func.avg(SoilMoistureReading.moisture_percent), + func.min(SoilMoistureReading.moisture_percent), + func.max(SoilMoistureReading.moisture_percent), + func.avg(SoilMoistureReading.soil_temperature_c), + func.min(SoilMoistureReading.soil_temperature_c), + func.max(SoilMoistureReading.soil_temperature_c), + ) + .filter(SoilMoistureReading.reading_timestamp >= since) + .one() + ) - moisture_values = [r.moisture_percent for r in recent_readings if r.moisture_percent is not None] - soil_temps = [r.soil_temperature_c for r in recent_readings if r.soil_temperature_c is not None] + def _r1(v: float | None) -> float | None: + return round(v, 1) if v is not None else None sensor_reading_stats = { - "total_readings": len(recent_readings), - "moisture": { - "avg": round(sum(moisture_values) / len(moisture_values), 1) if moisture_values else None, - "min": round(min(moisture_values), 1) if moisture_values else None, - "max": round(max(moisture_values), 1) if moisture_values else None, - }, - "soil_temperature": { - "avg": round(sum(soil_temps) / len(soil_temps), 1) if soil_temps else None, - "min": round(min(soil_temps), 1) if soil_temps else None, - "max": round(max(soil_temps), 1) if soil_temps else None, - }, + "total_readings": int(reading_agg[0] or 0), + "moisture": {"avg": _r1(reading_agg[1]), "min": _r1(reading_agg[2]), "max": _r1(reading_agg[3])}, + "soil_temperature": {"avg": _r1(reading_agg[4]), "min": _r1(reading_agg[5]), "max": _r1(reading_agg[6])}, } # ─── NPK BİTKİ PROFİLLERİ (statik - radar chart için) ──────── @@ -233,13 +241,18 @@ def compare_analytics( """ def _get_stats(start: datetime, end: datetime): - weather_records = ( - db.query(WeatherData).filter(WeatherData.recorded_at >= start, WeatherData.recorded_at <= end).all() + # AUDIT #22: SQL aggregate (belleğe satır çekmeden). AVG NULL'ları yok sayar + # (Python non-null filtresiyle aynı); SUM tümü NULL/boşsa None → 0.0'a düşülür. + weather_agg = ( + db.query( + func.avg(WeatherData.temperature_c), + func.avg(WeatherData.humidity_percent), + func.sum(WeatherData.precipitation_mm), + ) + .filter(WeatherData.recorded_at >= start, WeatherData.recorded_at <= end) + .one() ) - - temps = [r.temperature_c for r in weather_records if r.temperature_c is not None] - hums = [r.humidity_percent for r in weather_records if r.humidity_percent is not None] - precip = sum([r.precipitation_mm for r in weather_records if r.precipitation_mm is not None]) + temp_avg_v, humidity_avg_v, precip = weather_agg[0], weather_agg[1], (weather_agg[2] or 0.0) readings = ( db.query(func.count(SoilMoistureReading.id)) @@ -256,8 +269,10 @@ def _get_stats(start: datetime, end: datetime): ) return { - "temp_avg": round(sum(temps) / len(temps), 2) if temps else 0, - "humidity_avg": round(sum(hums) / len(hums), 2) if hums else 0, + # AUDIT FIX (#5): boş periyotta ortalama 0 yerine None döner; + # aksi halde _diff 0'ı gerçek baseline sanıp sahte %100 üretiyordu. + "temp_avg": round(temp_avg_v, 2) if temp_avg_v is not None else None, + "humidity_avg": round(humidity_avg_v, 2) if humidity_avg_v is not None else None, "precipitation_mm": round(precip, 2), "sensor_readings": readings, "irrigations": irrigations, @@ -266,14 +281,28 @@ def _get_stats(start: datetime, end: datetime): stats_1 = _get_stats(start_date_1, end_date_1) stats_2 = _get_stats(start_date_2, end_date_2) - def _diff(val1: float, val2: float) -> float: - if val1 == 0: - return 100.0 if val2 > 0 else 0.0 - return round(((val2 - val1) / val1) * 100, 2) + def _diff(val1: float | None, val2: float | None) -> float | None: + # AUDIT FIX (#5): baseline/current veri yoksa (None) veya baseline 0 ise + # anlamlı bir yüzde hesaplanamaz → None. + if val1 is None or val2 is None or val1 == 0: + return None + # AUDIT FIX (#4): negatif baseline'da yüzdenin işareti ters dönmesin diye + # paydada abs(val1) kullanılır; pay işareti (val2 - val1) korunur. + return round(((val2 - val1) / abs(val1)) * 100, 2) return { - "period_1": {"start": start_date_1, "end": end_date_1, "stats": stats_1}, - "period_2": {"start": start_date_2, "end": end_date_2, "stats": stats_2}, + # AUDIT FIX (L6): raw dict → datetime'ler tz'siz dönüyordu (UTC offset yok). + # API'nin geri kalanıyla uyumlu ISO 8601 (UTC suffix'li) için _serialize_utc. + "period_1": { + "start": _serialize_utc(start_date_1), + "end": _serialize_utc(end_date_1), + "stats": stats_1, + }, + "period_2": { + "start": _serialize_utc(start_date_2), + "end": _serialize_utc(end_date_2), + "stats": stats_2, + }, "comparison": { "temp_avg_diff_percent": _diff(stats_1["temp_avg"], stats_2["temp_avg"]), "humidity_avg_diff_percent": _diff(stats_1["humidity_avg"], stats_2["humidity_avg"]), diff --git a/app/routers/auth.py b/app/routers/auth.py index f81b35a..f2028fd 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -35,7 +35,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from jose import JWTError, jwt from passlib.context import CryptContext -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -67,6 +67,10 @@ # bcrypt context — round count default 12; production'da yüksek pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +# bcrypt algoritması parolayı 72 BYTE'ta sessizce keser (truncation). Audit +# fix (L3): bu sınırı aşan parolaları açıkça reddetmek için tek kaynak sabit. +_BCRYPT_MAX_BYTES = 72 + # JWT için stateless logout sağlayan in-memory blacklist. # `jti` (JWT ID, RFC 7519 §4.1.7) üzerinden çalışır — aynı kullanıcının aynı # saniyede aldığı iki token'ın `sub`+`iat`+`exp` payload'ı identical olabilir @@ -91,10 +95,10 @@ class UserRegisterRequest(BaseModel): } ) - name: str - email: str # TODO: switch to EmailStr once pydantic[email] is in. + name: str = Field(..., min_length=1, max_length=100) + email: str = Field(..., max_length=150) # TODO: EmailStr (email-validator gerekli) password: str # min 8 karakter (validator alttaki register'da) - phone: str | None = None + phone: str | None = Field(None, max_length=20) class UserLoginRequest(BaseModel): @@ -163,6 +167,19 @@ class PasswordChangeResponse(BaseModel): # ─── Yardımcılar ───────────────────────────────────────────────────── +def _is_email_sane(email: str) -> bool: + """Hafif e-posta sağlık kontrolü — EmailStr/email-validator yok. + + Audit fix (L1): en az 3 karakter, tam bir '@' ve '@'tan sonra bir '.' + içermeli. Tam RFC doğrulaması değil; boş/sahte adresleri eler. + EN: Lightweight email sanity check (no email-validator dependency). + """ + if len(email) < 3 or email.count("@") != 1: + return False + local, _, domain = email.partition("@") + return bool(local) and "." in domain and not domain.startswith(".") and not domain.endswith(".") + + def _hash_password(password: str) -> str: """bcrypt hash üret — `pwd_context` her seferinde yeni salt kullanır. @@ -204,7 +221,14 @@ def _create_token(user_id: int) -> tuple[str, int]: def _decode_token(token: str) -> int: """JWT decode + sub'ı user_id olarak döndür. Geçersizse 401 fırlat.""" try: - payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + # Audit fix (L2): exp + sub claim'leri zorunlu — exp'siz token süresiz + # geçerli kalmasın, sub'suz token kimliksiz kabul edilmesin. + payload = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + options={"require": ["exp", "sub"]}, + ) except JWTError as exc: raise UnauthorizedError(detail="Geçersiz token.") from exc # jti blacklist kontrolü — payload.get çünkü eski (jti'siz) token'lara @@ -307,8 +331,15 @@ def _dep(user: User = Depends(_get_current_user)) -> User: ) @limiter.limit(AUTH_RATE) def register(request: Request, payload: UserRegisterRequest, db: Session = Depends(get_db)) -> User: - if db.query(User).filter(User.email == payload.email).first(): - raise ConflictError(message="Bu e-posta zaten kayıtlı.") + # Audit fix: e-posta normalize (strip + lower) — case/whitespace varyantları + # mükerrer hesap yaratmasın ve dup-check ile insert aynı değeri kullansın. + email = payload.email.strip().lower() + # Audit fix (L1): hafif e-posta sağlık kontrolü — email-validator/EmailStr + # kurulu değil; en az '@' + '.' içermeli ve makul uzunlukta olmalı. + if not _is_email_sane(email): + raise ValidationError(message="Geçerli bir e-posta adresi girin.") + # Audit fix (L4): şifre uzunluğu kontrolü dup-email kontrolünden ÖNCE çalışır + # — geçersiz şifre 409 yerine 400 döner ve dup-check ile user enumeration azalır. if len(payload.password) < 8: # 400 — FastAPI'nin auto-generated 422 şeması list[ValidationError] # bekler; düz-string detail uyumsuz olur. @@ -316,9 +347,14 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen # 400 — FastAPI's auto-generated 422 schema expects # list[ValidationError]; a plain-string detail breaks it. raise ValidationError(message="Şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") + if db.query(User).filter(User.email == email).first(): + raise ConflictError(message="Bu e-posta zaten kayıtlı.") user = User( name=payload.name, - email=payload.email, + email=email, password_hash=_hash_password(payload.password), role="farmer", phone=payload.phone, @@ -344,7 +380,10 @@ def register(request: Request, payload: UserRegisterRequest, db: Session = Depen ) @limiter.limit(AUTH_RATE) def login(request: Request, payload: UserLoginRequest, db: Session = Depends(get_db)) -> TokenResponse: - user = db.query(User).filter(User.email == payload.email).first() + # Audit fix: register ile aynı normalize (strip + lower) — case/whitespace + # varyantı login'i bloklamasın, stored e-posta ile eşleşsin. + email = payload.email.strip().lower() + user = db.query(User).filter(User.email == email).first() if user is None or not _verify_password(payload.password, user.password_hash or ""): raise UnauthorizedError(detail="E-posta veya şifre hatalı.") token, expires_in = _create_token(user.id) @@ -433,6 +472,9 @@ def change_password( raise UnauthorizedError(detail="Mevcut şifre hatalı.") if len(payload.new_password) < 8: raise ValidationError(message="Yeni şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.new_password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Yeni şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") if payload.new_password == payload.current_password: raise ValidationError(message="Yeni şifre mevcut şifreyle aynı olamaz.") user.password_hash = _hash_password(payload.new_password) diff --git a/app/routers/farms.py b/app/routers/farms.py index 9395c99..a232257 100644 --- a/app/routers/farms.py +++ b/app/routers/farms.py @@ -26,7 +26,7 @@ from app.middleware.exceptions import ConflictError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import assert_farm_ownership, require_write, scope_to_user -from app.models.models import Farm, Field, SoilAnalysis, User +from app.models.models import Farm, Field, SoilAnalysis, SystemAlert, User, WeatherData from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import ( FarmCreate, @@ -188,6 +188,9 @@ def update_farm( require_write(current_user) assert_farm_ownership(db, farm_id, current_user) farm = db.query(Farm).filter(Farm.id == farm_id).first() + if farm is None: + # assert_farm_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Çiftlik") updates = payload.model_dump(exclude_unset=True) for field_name, value in updates.items(): setattr(farm, field_name, value) @@ -222,5 +225,13 @@ def delete_farm( if field_count > 0: raise ConflictError(message=f"Bu çiftliğin {field_count} tarlası var; önce tarlaları sil.") farm = db.query(Farm).filter(Farm.id == farm_id).first() + if farm is None: + # assert_farm_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Çiftlik") + # Cascade: çiftliğe doğrudan bağlı hava-durumu + uyarı satırları onunla silinir + # → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). Tarlalar + # yukarıda guard'la bloke (önce onlar silinir). + db.query(WeatherData).filter(WeatherData.farm_id == farm_id).delete(synchronize_session=False) + db.query(SystemAlert).filter(SystemAlert.farm_id == farm_id).delete(synchronize_session=False) db.delete(farm) db.commit() diff --git a/app/routers/fields.py b/app/routers/fields.py index b34cc8a..9f8e0ff 100644 --- a/app/routers/fields.py +++ b/app/routers/fields.py @@ -37,8 +37,10 @@ from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import assert_farm_ownership, assert_field_ownership, require_write from app.models.models import ( + CropPlanting, CropType, Farm, + FertilizerRecommendationLog, Field, IrrigationSchedule, PlantHealthImage, @@ -49,6 +51,7 @@ User, ) from app.routers.auth import get_current_user_or_403 +from app.schemas.base import _serialize_utc from app.schemas.schemas import ( FieldAlertSummary, FieldCreate, @@ -213,7 +216,10 @@ def get_field_detail( region=farm.region, city=farm.city, crop=FieldCropInfo.model_validate(crop) if crop else None, - moisture_status=_classify_moisture(avg_moisture), + # AUDIT FIX (L9): rozet ile gösterilen sayı uyumlu olsun diye sınıflandırma + # da gösterilen YUVARLANMIŞ değer üzerinden yapılır (ör. 29.95 → 30.0% optimal, + # ham değerde 'dry' rozeti çelişkisi olmaz). + moisture_status=_classify_moisture(round(avg_moisture, 1) if avg_moisture is not None else None), avg_moisture_percent=round(avg_moisture, 1) if avg_moisture is not None else None, sensors=sensor_summaries, recent_irrigations=[FieldIrrigationSummary.model_validate(i) for i in irrigations], @@ -256,7 +262,10 @@ def get_field_readings( # En yeniden çektik; grafik için kronolojik (eskiden yeniye) ters çevir. result = [ { - "reading_timestamp": str(reading.reading_timestamp), + # Audit fix (#9): raw str() çıktısı UTC offset taşımıyordu → + # Safari'de "Invalid Date" / yanlış-gün etiketleri. API'nin geri + # kalanıyla uyumlu ISO 8601 (UTC suffix'li) için _serialize_utc. + "reading_timestamp": _serialize_utc(reading.reading_timestamp), "sensor_id": reading.sensor_id, "sensor_type": sensor_type, "moisture_percent": reading.moisture_percent, @@ -322,6 +331,9 @@ def update_field( require_write(current_user) assert_field_ownership(db, field_id, current_user) field = db.query(Field).filter(Field.id == field_id).first() + if field is None: + # assert_field_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Tarla") updates = payload.model_dump(exclude_unset=True) for field_name, value in updates.items(): setattr(field, field_name, value) @@ -356,5 +368,20 @@ def delete_field( if sensor_count > 0: raise ConflictError(message=f"Bu tarlanın {sensor_count} sensörü var; önce sensörleri sil.") field = db.query(Field).filter(Field.id == field_id).first() + if field is None: + # assert_field_ownership zaten 404 fırlattı; defensive (race — satır silinmiş olabilir). + raise NotFoundError("Tarla") + # Cascade: tarlanın veri-kayıtları (analiz/sulama/görsel/ekim/gübre/uyarı) + # onunla silinir → aksi halde FK IntegrityError ile prod'da 500 (audit YÜKSEK). + # Sensörler yukarıda guard'la bloke (önce onlar silinir). + for _model, _fk in ( + (SoilAnalysis, SoilAnalysis.field_id), + (IrrigationSchedule, IrrigationSchedule.field_id), + (PlantHealthImage, PlantHealthImage.field_id), + (CropPlanting, CropPlanting.field_id), + (FertilizerRecommendationLog, FertilizerRecommendationLog.field_id), + (SystemAlert, SystemAlert.field_id), + ): + db.query(_model).filter(_fk == field_id).delete(synchronize_session=False) db.delete(field) db.commit() diff --git a/app/routers/irrigation.py b/app/routers/irrigation.py index 90a4dc5..1e47f47 100644 --- a/app/routers/irrigation.py +++ b/app/routers/irrigation.py @@ -25,7 +25,7 @@ from sqlalchemy.orm import Session from app.database import MAX_SQLITE_INT, get_db -from app.middleware.exceptions import NotFoundError +from app.middleware.exceptions import ConflictError, NotFoundError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.middleware.rbac import _BYPASS_ROLES, assert_field_ownership, require_write from app.ml.irrigation_model import irrigation_optimizer @@ -47,6 +47,10 @@ # sensors.py ile aynı int64 overflow koruması — skip 1M ile sınırlanır. MAX_SKIP = 1_000_000 +# Terminal durumlar: tamamlanan/iptal edilen program geri açılamaz (one-way). +# Bu durumlardan çıkış (ör. completed → pending) 409 ile bloke edilir. +_TERMINAL_STATUSES = frozenset({"completed", "cancelled"}) + router = APIRouter(prefix="/api/irrigation", tags=["Sulama Optimizasyonu"]) @@ -189,6 +193,7 @@ def create_schedule( 401: {"description": "Bearer token gerekli"}, 403: {"description": "Yazma yetkisi yok veya başkasının tarlası"}, 404: {"description": "Program bulunamadı"}, + 409: {"description": "Terminal durumdaki program (completed/cancelled) güncellenemez"}, }, ) @limiter.limit(STRICT_RATE) @@ -206,6 +211,9 @@ def update_schedule_status( raise NotFoundError("Sulama programı") # field ownership: schedule.field_id farmer'ın olmalı assert_field_ownership(db, schedule.field_id, current_user) + # Terminal durumdan (completed/cancelled) çıkış engellenir — geri açılamaz. + if schedule.status in _TERMINAL_STATUSES: + raise ConflictError(message=f"'{schedule.status}' durumundaki program güncellenemez (terminal durum).") schedule.status = payload.status db.commit() db.refresh(schedule) diff --git a/app/routers/metrics.py b/app/routers/metrics.py index c888907..c1388a9 100644 --- a/app/routers/metrics.py +++ b/app/routers/metrics.py @@ -27,6 +27,7 @@ from app.config import settings from app.database import get_db from app.models.models import Sensor, SoilMoistureReading, SystemAlert +from app.routers.auth import require_role from app.schemas.schemas import HealthCheckResponse router = APIRouter(prefix="/api/health", tags=["Sistem Metrikleri"]) @@ -180,6 +181,10 @@ def _check_alerts(db: Session) -> dict: summary="Derin sistem sağlığı kontrolü", description="DB, scheduler ve ML model bileşenlerini kontrol eder. " "Herhangi bir bileşen 'fail' olursa overall 'degraded' döndürür.", + # AUDIT FIX (#6): scheduler iş listesi / pool internals / ham DB hata + # string'leri sızdıran bu uç artık yalnız admin/developer'a açık. + dependencies=[Depends(require_role("admin", "developer"))], + responses={401: {"description": "Bearer token gerekli"}, 403: {"description": "admin/developer rolü gerekli"}}, ) def deep_health_check(db: Session = Depends(get_db)) -> HealthCheckResponse: components = { @@ -219,7 +224,14 @@ def deep_health_check(db: Session = Depends(get_db)) -> HealthCheckResponse: "library bağımlılığı olmadan basit gauge'lar yayar. Production'da bir " "Prometheus instance'ı buradan periyodik scrape eder." ), - responses={200: {"content": {"text/plain": {}}, "description": "Prometheus exposition format"}}, + # AUDIT FIX (#6): aktif sensör / alert sayıları gibi sistem iç metriklerini + # sızdıran scrape ucu artık yalnız admin/developer'a açık. + dependencies=[Depends(require_role("admin", "developer"))], + responses={ + 200: {"content": {"text/plain": {}}, "description": "Prometheus exposition format"}, + 401: {"description": "Bearer token gerekli"}, + 403: {"description": "admin/developer rolü gerekli"}, + }, ) def prometheus_metrics(db: Session = Depends(get_db)) -> PlainTextResponse: """Lightweight Prometheus exposition — manual text format (no client_python dep).""" diff --git a/app/routers/model_performance.py b/app/routers/model_performance.py index 2157f7c..0e79bdb 100644 --- a/app/routers/model_performance.py +++ b/app/routers/model_performance.py @@ -31,6 +31,7 @@ from app.middleware.exceptions import NotFoundError, ValidationError from app.middleware.rate_limiter import STRICT_RATE, limiter from app.models.models import ModelPerformanceLog, SystemAlert +from app.routers.auth import get_current_user_or_403, require_role from app.schemas.schemas import ( ModelPerformanceCompareItem, ModelPerformanceDriftReport, @@ -62,6 +63,9 @@ "/", response_model=list[ModelPerformanceLogResponse], summary="Performans loglarını listele", + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={401: {"description": "Bearer token gerekli"}}, ) def list_logs( model_name: str | None = Query(default=None, description="Filtre: belirli bir model"), @@ -128,7 +132,12 @@ def update_log( response_model=ModelPerformanceSummary, summary="Model bazlı agregat performans özeti", description="Belirtilen model için toplam tahmin sayısı, ortalama doğruluk skoru ve son log zamanını döndürür.", - responses={404: {"description": "Belirtilen model için log bulunamadı"}}, + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={ + 401: {"description": "Bearer token gerekli"}, + 404: {"description": "Belirtilen model için log bulunamadı"}, + }, ) def model_summary(model_name: str, db: Session = Depends(get_db)) -> ModelPerformanceSummary: rows = ( @@ -156,6 +165,9 @@ def model_summary(model_name: str, db: Session = Depends(get_db)) -> ModelPerfor summary="Günlük accuracy zaman serisi", description="Modelin son N gündeki günlük ortalama accuracy değerini döndürür. " "Trend takibi ve drift tespiti için kullanılır.", + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={401: {"description": "Bearer token gerekli"}}, ) def model_timeseries( model_name: str, @@ -194,7 +206,12 @@ def model_timeseries( summary="Birden fazla modeli karşılaştır", description="Virgülle ayrılmış model isimleri için yan-yana metrikler (toplam tahmin, " "ortalama / min / max accuracy, son log zamanı). Örnek: `?models=irrigation_rf,plant_disease_cnn`", - responses={400: {"description": "Geçerli model adı sağlanmadı"}}, + # AUDIT FIX (#7): read ucu artık Bearer token zorunlu kılıyor (auth eksikti). + dependencies=[Depends(get_current_user_or_403)], + responses={ + 400: {"description": "Geçerli model adı sağlanmadı"}, + 401: {"description": "Bearer token gerekli"}, + }, ) def compare_models( models: str = Query(..., description="Virgülle ayrılmış model isimleri"), @@ -247,6 +264,10 @@ def compare_models( description="Son N gün accuracy ortalamasını önceki periyotla karşılaştırır. " "Belirlenen eşikten fazla düşüş varsa otomatik olarak `SystemAlert` (severity=medium) yaratır. " "İdeal sıklık: günde 1-2 kez (cron veya scheduler ile).", + # AUDIT FIX (#7): SystemAlert INSERT eden write-yan-etkili uç; auth eksikti. + # Yazma işlemi olduğundan yalnız admin/developer'a açıldı. + dependencies=[Depends(require_role("admin", "developer"))], + responses={401: {"description": "Bearer token gerekli"}, 403: {"description": "admin/developer rolü gerekli"}}, ) def detect_drift( model_name: str, diff --git a/app/routers/plants.py b/app/routers/plants.py index f7b866a..d23dfd2 100644 --- a/app/routers/plants.py +++ b/app/routers/plants.py @@ -167,6 +167,15 @@ async def analyze_plant_image( # ─── CNN modelinden tahmin ─────────────────────────────────── prediction = plant_disease_model.predict(content) + # Audit fix (#16): görsel decode edilemezse model error dict döner + # (diagnosis='unknown', confidence 0.0). Bunu 200 başarı + sahte satır + # olarak kaydetmek yerine 4xx ile reddet, DB'ye yazma. + if prediction.get("error") or prediction.get("diagnosis") == "unknown": + raise HTTPException( + status_code=422, + detail=prediction.get("error") or "Goruntu analiz edilemedi (gecersiz/bozuk gorsel).", + ) + # ─── DB kaydı ──────────────────────────────────────────────── record = PlantHealthImage( field_id=field_id, @@ -176,7 +185,14 @@ async def analyze_plant_image( severity=prediction["severity"], ) db.add(record) - db.commit() + # Audit fix (L19): dosya commit'ten önce diske yazıldığı için commit + # başarısız olursa yetim dosya kalır. Commit'i sar, hata olursa dosyayı sil. + try: + db.commit() + except Exception: + db.rollback() + save_path.unlink(missing_ok=True) + raise db.refresh(record) return { diff --git a/app/routers/sensors.py b/app/routers/sensors.py index 09fb994..68ea6c8 100644 --- a/app/routers/sensors.py +++ b/app/routers/sensors.py @@ -25,7 +25,7 @@ require_write, scope_sensors_to_user, ) -from app.models.models import Sensor, SoilMoistureReading, User +from app.models.models import Sensor, SensorReadingMonthlyAggregate, SoilMoistureReading, User from app.routers.auth import get_current_user_or_403 from app.schemas.schemas import SensorCreate, SensorReadingCreate, SensorReadingResponse, SensorResponse @@ -142,6 +142,13 @@ def delete_sensor( if not sensor: # assert_sensor_ownership zaten 404 fırlattı; race condition defensive. raise NotFoundError("Sensör") + # Cascade: sensörün okuma + aylık-özet satırları onunla silinir. Gerçek + # sensörlerin HER ZAMAN okuması olur → guard yerine cascade şart; aksi halde + # FK IntegrityError ile prod'da 500, SQLite'ta orphan satır (audit YÜKSEK). + db.query(SoilMoistureReading).filter(SoilMoistureReading.sensor_id == sensor_id).delete(synchronize_session=False) + db.query(SensorReadingMonthlyAggregate).filter(SensorReadingMonthlyAggregate.sensor_id == sensor_id).delete( + synchronize_session=False + ) db.delete(sensor) db.commit() return {"message": "Sensor silindi"} diff --git a/app/routers/users.py b/app/routers/users.py index 5f710d2..1036649 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -24,7 +24,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Path, Query, Request, status -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func from sqlalchemy.orm import Session @@ -37,10 +37,12 @@ from app.middleware.rate_limiter import AUTH_RATE, limiter from app.models.models import Farm, User from app.routers.auth import ( + _BCRYPT_MAX_BYTES, CurrentUserResponse, PasswordChangeResponse, UserRole, _hash_password, + _is_email_sane, require_role, ) from app.schemas.schemas import UtcDateTime @@ -80,11 +82,11 @@ class AdminUserCreateRequest(BaseModel): } ) - name: str - email: str + name: str = Field(..., min_length=1, max_length=100) + email: str = Field(..., max_length=150) password: str role: UserRole = "farmer" - phone: str | None = None + phone: str | None = Field(None, max_length=20) class AdminPasswordResetRequest(BaseModel): @@ -216,13 +218,24 @@ def admin_create_user( db: Session = Depends(get_db), ) -> User: """Admin rol seçerek kullanıcı yaratır.""" - if db.query(User).filter(User.email == payload.email).first(): - raise ConflictError(message="Bu e-posta zaten kayıtlı.") + # Audit fix: e-posta normalize (strip + lower) — auth.register ile tutarlı; + # case/whitespace varyantı mükerrer hesap yaratmasın, dup-check ile insert eşleşsin. + email = payload.email.strip().lower() + # Audit fix (L1): hafif e-posta sağlık kontrolü — auth.register ile tutarlı. + if not _is_email_sane(email): + raise ValidationError(message="Geçerli bir e-posta adresi girin.") + # Audit fix (L4): şifre uzunluğu kontrolü dup-email'den önce — geçersiz şifre + # 409 yerine 400 döner, user enumeration azalır. if len(payload.password) < 8: raise ValidationError(message="Şifre en az 8 karakter olmalı.") + # Audit fix (L3): bcrypt 72 BYTE'ta sessizce keser; açıkça reddet. + if len(payload.password.encode("utf-8")) > _BCRYPT_MAX_BYTES: + raise ValidationError(message=f"Şifre en fazla {_BCRYPT_MAX_BYTES} bayt olmalı.") + if db.query(User).filter(User.email == email).first(): + raise ConflictError(message="Bu e-posta zaten kayıtlı.") user = User( name=payload.name, - email=payload.email, + email=email, password_hash=_hash_password(payload.password), role=payload.role, phone=payload.phone, diff --git a/app/schemas/alerts.py b/app/schemas/alerts.py index c5cdf22..6ab440c 100644 --- a/app/schemas/alerts.py +++ b/app/schemas/alerts.py @@ -2,7 +2,9 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime @@ -25,8 +27,9 @@ class SystemAlertCreate(BaseModel): farm_id: SqliteSafeInt | None = None field_id: SqliteSafeInt | None = None - alert_type: str # 'sensor_anomaly' | 'weather_warning' | 'system_error' | ... - severity: str = "low" # 'low' | 'medium' | 'critical' + alert_type: str = Field(..., max_length=50) # 'sensor_anomaly' | 'weather_warning' | ... + # Audit düzeltmesi: keyfi severity değerleri dashboard/metrik kovalarını bozmasın. + severity: Literal["low", "medium", "critical"] = "low" message: str @@ -42,12 +45,14 @@ class SystemAlertResponse(BaseModel): severity: str message: str is_resolved: bool - created_at: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + created_at: UtcDateTime | None = None class SystemAlertUpdate(BaseModel): """Alert'in resolved durumunu güncellemek icin kismi update.""" is_resolved: bool | None = None - severity: str | None = None + # Audit düzeltmesi: severity sadece geçerli kovalarla kısıtlandı. + severity: Literal["low", "medium", "critical"] | None = None message: str | None = None diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index daa921d..e7828e9 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -58,7 +58,7 @@ class DashboardLastDisease(BaseModel): field_name: str | None = None captured_at: UtcDateTime | None = None diagnosis: str | None = None - severity: str | None = None # 'none' | 'mild' | 'moderate' | 'severe' + severity: str | None = None # 'none' | 'low' | 'medium' | 'high' confidence_score: float | None = None diff --git a/app/schemas/farms.py b/app/schemas/farms.py index 7fd7801..cf3b3a1 100644 --- a/app/schemas/farms.py +++ b/app/schemas/farms.py @@ -2,10 +2,14 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime +# NOT: max_length DB kolon uzunluklarıyla hizalı (User/Farm/Field). Aksi halde +# aşırı-uzun string SQLite'ta geçer ama PostgreSQL'de 500 (StringDataRightTruncation) +# verirdi (audit YÜKSEK: SQLite↔PG ayrışması). Burada Pydantic 422 ile erken döner. + # ========== FARM / FIELD / SOIL (Cycle 9 GET endpoint'leri) ========== class FieldSummary(BaseModel): @@ -59,9 +63,9 @@ class FarmCreate(BaseModel): } ) - name: str - city: str | None = None - region: str | None = None + name: str = Field(..., max_length=150) + city: str | None = Field(None, max_length=100) + region: str | None = Field(None, max_length=100) area_hectares: float | None = None location_lat: float | None = None location_lng: float | None = None @@ -70,9 +74,9 @@ class FarmCreate(BaseModel): class FarmUpdate(BaseModel): """Çiftlik kısmi güncelleme — yalnız verilen alanlar değişir (exclude_unset).""" - name: str | None = None - city: str | None = None - region: str | None = None + name: str | None = Field(None, max_length=150) + city: str | None = Field(None, max_length=100) + region: str | None = Field(None, max_length=100) area_hectares: float | None = None location_lat: float | None = None location_lng: float | None = None @@ -95,8 +99,8 @@ class FieldCreate(BaseModel): ) farm_id: SqliteSafeInt - name: str - soil_type: str | None = None + name: str = Field(..., max_length=150) + soil_type: str | None = Field(None, max_length=50) area_hectares: float | None = None elevation_m: float | None = None crop_id: SqliteSafeInt | None = None @@ -105,8 +109,8 @@ class FieldCreate(BaseModel): class FieldUpdate(BaseModel): """Tarla kısmi güncelleme — yalnız verilen alanlar değişir.""" - name: str | None = None - soil_type: str | None = None + name: str | None = Field(None, max_length=150) + soil_type: str | None = Field(None, max_length=50) area_hectares: float | None = None elevation_m: float | None = None crop_id: SqliteSafeInt | None = None @@ -119,7 +123,8 @@ class SoilAnalysisResponse(BaseModel): id: int field_id: int - analysis_date: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + analysis_date: UtcDateTime | None = None ph_level: float | None = None organic_matter_pct: float | None = None nitrogen_mg_kg: float | None = None diff --git a/app/schemas/fertilizer.py b/app/schemas/fertilizer.py index f238d34..fb23da8 100644 --- a/app/schemas/fertilizer.py +++ b/app/schemas/fertilizer.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator # ========== FERTILIZER (Gübreleme) ========== @@ -29,7 +29,9 @@ class FertilizerRecommendRequest(BaseModel): soil_nitrogen: float # mg/kg soil_phosphorus: float # mg/kg soil_potassium: float # mg/kg - area_hectares: float + # Negatif/sıfır alan → negatif gübre kg veya yanlış "toprak yeterli" sonucu + # (audit YÜKSEK). gt=0 ile Pydantic 422 döner, servis hiç çalışmaz. + area_hectares: float = Field(..., gt=0.0) class FertilizerRecommendResponse(BaseModel): @@ -52,7 +54,7 @@ class FertilizerScheduleRequest(BaseModel): crop_type: str planting_date: str # YYYY-MM-DD - area_hectares: float + area_hectares: float = Field(..., gt=0.0) # negatif/sıfır alan reddedilir (audit YÜKSEK) soil_nitrogen: float = 0.0 soil_phosphorus: float = 0.0 soil_potassium: float = 0.0 diff --git a/app/schemas/irrigation.py b/app/schemas/irrigation.py index ea7cf9c..c2c430e 100644 --- a/app/schemas/irrigation.py +++ b/app/schemas/irrigation.py @@ -16,8 +16,11 @@ class IrrigationCreate(BaseModel): field_id: SqliteSafeInt scheduled_date: datetime - duration_min: int | None = None - water_amount_liters: float | None = None + # Audit düzeltmesi: negatif süre ve negatif/sıfır su miktarı reddedilir. + duration_min: int | None = Field(None, ge=0) + water_amount_liters: float | None = Field(None, gt=0) + # Audit düzeltmesi: manuel program model default'u ('model') ile etiketlenmesin. + source: str = "manual" class IrrigationResponse(BaseModel): @@ -28,6 +31,8 @@ class IrrigationResponse(BaseModel): id: int field_id: int scheduled_date: UtcDateTime + # Audit düzeltmesi: frontend "Süre (dk)" kolonu için duration_min eksikti. + duration_min: int | None water_amount_liters: float | None status: str diff --git a/app/schemas/sensors.py b/app/schemas/sensors.py index 028650f..d65043e 100644 --- a/app/schemas/sensors.py +++ b/app/schemas/sensors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.schemas.base import SqliteSafeInt, UtcDateTime @@ -25,8 +25,10 @@ class SensorCreate(BaseModel): ) field_id: SqliteSafeInt - sensor_type: str # 'soil_moisture' | 'soil_temperature' | 'humidity' | ... - serial_number: str + # max_length DB kolonlarıyla hizalı (sensor_type=50, serial_number=100) → + # PG'de 500 yerine 422; serbest str olduğu için frontend ayrıca escape eder. + sensor_type: str = Field(..., max_length=50) # 'soil_moisture' | 'soil_temperature' | ... + serial_number: str = Field(..., max_length=100) depth_cm: float | None = None lat: float | None = None lng: float | None = None @@ -62,6 +64,7 @@ class SensorReadingResponse(BaseModel): id: int sensor_id: int - reading_timestamp: UtcDateTime + # Audit düzeltmesi: DB kolonu nullable → NULL gelirse serialize'da 500 olmasın. + reading_timestamp: UtcDateTime | None = None moisture_percent: float soil_temperature_c: float | None diff --git a/app/schemas/weather.py b/app/schemas/weather.py index 264d4d5..2be072e 100644 --- a/app/schemas/weather.py +++ b/app/schemas/weather.py @@ -29,3 +29,4 @@ class WeatherDataResponse(BaseModel): temperature_c: float | None humidity_percent: float | None precipitation_mm: float | None + wind_speed_kmh: float | None = None diff --git a/app/services/mqtt_listener.py b/app/services/mqtt_listener.py index ea8d9e1..4599430 100644 --- a/app/services/mqtt_listener.py +++ b/app/services/mqtt_listener.py @@ -169,8 +169,14 @@ def _save_reading(db: Session, sensor_id: int, payload: dict[str, Any]) -> bool: if sensor is None: logger.warning(f"Bilinmeyen sensor_id={sensor_id}, mesaj atlandi") return False + # Audit fix (#15): moisture_percent eksik/None ise 0'a düşürmek yerine + # okumayı atla — aksi halde sahte %0 değeri sınır kontrolünü geçip kaydedilir. + raw_moisture = payload.get("moisture_percent") + if raw_moisture is None: + logger.warning(f"moisture_percent eksik, okuma atlandi: {payload!r}") + return False try: - moisture = float(payload.get("moisture_percent", 0)) + moisture = float(raw_moisture) except (TypeError, ValueError): logger.warning(f"Gecersiz moisture_percent: {payload!r}") return False diff --git a/app/services/report_service.py b/app/services/report_service.py index 0c5a7f7..ba05874 100644 --- a/app/services/report_service.py +++ b/app/services/report_service.py @@ -126,8 +126,10 @@ def generate_pdf_report(data: dict) -> io.BytesIO: for w in data.get("farm_weather_comparison", []): name = _clean_tr(w.get("farm_name", "")) city = _clean_tr(w.get("city", "")) - temp = w.get("temperature", {}).get("avg", "N/A") - hum = w.get("humidity", {}).get("avg", "N/A") + # Audit fix (L22): get(key, "N/A") yalnızca eksik anahtarda default verir; + # değer None ise literal 'None' basılır. None'ı da "N/A"ya indir. + temp = w.get("temperature", {}).get("avg") or "N/A" + hum = w.get("humidity", {}).get("avg") or "N/A" pdf.cell(0, 8, f"- {name} ({city}): Sicaklik {temp} C, Nem {hum}%", new_x="LMARGIN", new_y="NEXT") pdf.ln(5) @@ -137,8 +139,9 @@ def generate_pdf_report(data: dict) -> io.BytesIO: pdf.cell(0, 10, "3. Sensor Ortalamalari", new_x="LMARGIN", new_y="NEXT") pdf.set_font("helvetica", "", 12) stats = data.get("sensor_reading_stats", {}) - moisture = stats.get("moisture", {}).get("avg", "N/A") - soil_temp = stats.get("soil_temperature", {}).get("avg", "N/A") + # Audit fix (L22): None değer literal 'None' basıyordu; "N/A"ya indir. + moisture = stats.get("moisture", {}).get("avg") or "N/A" + soil_temp = stats.get("soil_temperature", {}).get("avg") or "N/A" pdf.cell(0, 8, f"- Ortalama Toprak Nemi: {moisture}%", new_x="LMARGIN", new_y="NEXT") pdf.cell(0, 8, f"- Ortalama Toprak Sicakligi: {soil_temp} C", new_x="LMARGIN", new_y="NEXT") diff --git a/app/services/sensor_archiver.py b/app/services/sensor_archiver.py index 72de31f..455f738 100644 --- a/app/services/sensor_archiver.py +++ b/app/services/sensor_archiver.py @@ -65,9 +65,11 @@ def _aggregate_group(rows: list[SoilMoistureReading]) -> dict: return { "reading_count": len(rows), - "moisture_avg": sum(moistures) / len(moistures) if moistures else 0.0, - "moisture_min": min(moistures) if moistures else 0.0, - "moisture_max": max(moistures) if moistures else 0.0, + # Audit fix (L21): boş grupta 0.0 ile seed etmek, sonraki min/max + # merge'ini bozuyordu (yapay 0.0 minimumu). None kullan; merge None-safe. + "moisture_avg": sum(moistures) / len(moistures) if moistures else None, + "moisture_min": min(moistures) if moistures else None, + "moisture_max": max(moistures) if moistures else None, "soil_temperature_avg": sum(temps) / len(temps) if temps else None, "soil_temperature_min": min(temps) if temps else None, "soil_temperature_max": max(temps) if temps else None, @@ -96,8 +98,19 @@ def _weighted(old_avg: float | None, new_avg: float | None) -> float | None: return (old_avg * existing.reading_count + new_avg * group["reading_count"]) / total_count existing.moisture_avg = _weighted(existing.moisture_avg, group["moisture_avg"]) - existing.moisture_min = min(existing.moisture_min, group["moisture_min"]) - existing.moisture_max = max(existing.moisture_max, group["moisture_max"]) + # Audit fix (L21): grup min/max artık None olabilir (boş grup). None-safe birleştir. + if group["moisture_min"] is not None: + existing.moisture_min = ( + min(existing.moisture_min, group["moisture_min"]) + if existing.moisture_min is not None + else group["moisture_min"] + ) + if group["moisture_max"] is not None: + existing.moisture_max = ( + max(existing.moisture_max, group["moisture_max"]) + if existing.moisture_max is not None + else group["moisture_max"] + ) existing.soil_temperature_avg = _weighted(existing.soil_temperature_avg, group["soil_temperature_avg"]) if group["soil_temperature_min"] is not None: existing.soil_temperature_min = ( @@ -131,6 +144,9 @@ def archive_old_readings(db: Session, cutoff_days: int = DEFAULT_ARCHIVE_CUTOFF_ """ cutoff = datetime.now(UTC) - timedelta(days=cutoff_days) old_readings = db.query(SoilMoistureReading).filter(SoilMoistureReading.reading_timestamp < cutoff).all() + # Audit fix (L20): DELETE penceresi SELECT'ten ayrı çalışıyordu; arada gelen + # geç kayıt aggregate'e girmeden silinebiliyordu. Sadece bu id kümesini sil. + archived_ids = [r.id for r in old_readings] if not old_readings: logger.info(f"sensor_archiver: cutoff={cutoff.isoformat()} — taşınacak kayıt yok") @@ -171,10 +187,11 @@ def archive_old_readings(db: Session, cutoff_days: int = DEFAULT_ARCHIVE_CUTOFF_ aggregates_written += 1 # Kaynak okumaları sil (cascade FK yok, manuel delete güvenli) - # EN: Delete source readings; no cascading FK so manual delete is safe. + # EN: Delete only the exact rows we aggregated (by id), not the time + # window — avoids deleting late-arriving rows that were never folded in. deleted = ( db.query(SoilMoistureReading) - .filter(SoilMoistureReading.reading_timestamp < cutoff) + .filter(SoilMoistureReading.id.in_(archived_ids)) .delete(synchronize_session=False) ) db.commit() diff --git a/app/services/weather_service.py b/app/services/weather_service.py index f7c6ebe..b609973 100644 --- a/app/services/weather_service.py +++ b/app/services/weather_service.py @@ -93,10 +93,18 @@ def _transform_api_response(self, raw: dict) -> dict: rain = raw.get("rain", {}) clouds = raw.get("clouds", {}) + # Yağış: anahtar var ama değer null ise (rain={"1h": None}) get default'u + # devreye girmez; bu yüzden None'ları açıkça 0.0'a indir. + precip = rain.get("1h") + if precip is None: + precip = rain.get("3h") + if precip is None: + precip = 0.0 + return { "temperature_c": main.get("temp"), "humidity_percent": main.get("humidity"), - "precipitation_mm": rain.get("1h", rain.get("3h", 0.0)), + "precipitation_mm": precip, "wind_speed_kmh": round((wind.get("speed", 0) * 3.6), 2), # m/s → km/h "solar_radiation": self._estimate_solar_radiation(clouds.get("all", 50)), "uv_index": None, # Ayrı API çağrısı gerekir diff --git a/docker-compose.yml b/docker-compose.yml index af2682d..c3acc2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,9 +33,14 @@ services: # DATABASE_URL=postgresql://sfdap_user:sfdap_password@db:5432/sfdap_db - DATABASE_URL=${DATABASE_URL:-sqlite:///./sfdap_dev.db} - CORS_ORIGINS=${CORS_ORIGINS:-https://${DOMAIN:-localhost}} - volumes: - - .:/app - - /app/.venv + # Audit fix: host kaynak bind mount (`.:/app`) prod image'in uzerine host + # kodunu + host .env/dev DB'sini bindirip prod container'in host kodu + # calistirmasina sebep oluyordu. Image'i self-contained tutmak icin + # default'tan kaldirildi. Local dev'de canli reload icin asagidaki + # `volumes` blogunu yorumdan cikarin: + # volumes: + # - .:/app + # - /app/.venv restart: unless-stopped networks: - sfdap_net diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..22d9f70 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# SFDAP container entrypoint +# ========================== +# Prod'da DB şeması YALNIZ alembic migration'larıyla kurulur (create_all main.py +# lifespan'inde prod-dışına gate'li). Böylece şema alembic_version ile stamp'lenir, +# RBAC CHECK constraint'i + FK index'leri dahil olur ve `alembic upgrade head` +# DuplicateTable ile patlamaz (audit KRİTİK fix). +# Dev/test'te create_all yeterli olduğu için migration adımı yalnız production'da koşar. +set -e + +if [ "$ENVIRONMENT" = "production" ]; then + echo "[entrypoint] production → alembic upgrade head" + alembic upgrade head +fi + +# --proxy-headers + --forwarded-allow-ips: nginx'in X-Forwarded-For'una güvenip +# request.client.host'u GERÇEK client IP'sine çevirir → rate-limit + access-log +# nginx peer IP'si yerine gerçek client'ı görür (audit YÜKSEK: aksi halde tüm +# trafik tek bucket'a düşer, auth brute-force koruması çöker). +# api yalnız nginx'in eriştiği internal ağda (expose, public değil) olduğu için +# default '*' güvenli; daha sıkı kurulumda FORWARDED_ALLOW_IPS= verin. +echo "[entrypoint] uvicorn başlatılıyor..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \ + --proxy-headers --forwarded-allow-ips="${FORWARDED_ALLOW_IPS:-*}" diff --git a/frontend/index.html b/frontend/index.html index a7389bd..93aa36a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -161,19 +161,24 @@

Toprağın dijital nabzı

- + - + + - + + + - + + + @@ -183,22 +188,28 @@

Toprağın dijital nabzı

+ + + + - + + + - + @@ -206,10 +217,12 @@

Toprağın dijital nabzı

+ + - + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 31b72c9..a1c7d12 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -14,13 +14,17 @@ export function setAuthToken(t) { localStorage.setItem(AUTH_TOKEN_KEY, t); } export function clearAuthToken() { localStorage.removeItem(AUTH_TOKEN_KEY); } /** - * Auth header builder — Bearer token varsa Authorization, yoksa X-API-Key. + * Auth header builder — Bearer token varsa Authorization döner. + * Audit fix (#26): hardcoded 'dev-api-key' fallback prod bundle'a sızıyordu ve + * gerçek prod key ile asla eşleşmiyordu. Pano Bearer-tabanlı olduğu için artık + * yalnızca açıkça yapılandırılmış (window.SFDAP_API_KEY) gerçek bir key varsa + * X-API-Key gönderilir; aksi halde hiç auth header eklenmez. */ export function _authHeaders() { const token = localStorage.getItem(AUTH_TOKEN_KEY); - return token - ? { 'Authorization': `Bearer ${token}` } - : { 'X-API-Key': 'dev-api-key' }; + if (token) return { 'Authorization': `Bearer ${token}` }; + const apiKey = window.SFDAP_API_KEY; + return apiKey ? { 'X-API-Key': apiKey } : {}; } /** @@ -31,6 +35,12 @@ export async function _extractErrorMessage(res) { const body = await res.clone().json(); if (body && typeof body.message === "string" && body.message.trim()) return body.message; if (body && typeof body.detail === "string" && body.detail.trim()) return body.detail; + // Pydantic 422: detail bir dizi ({loc,msg,type}) → alan mesajlarını birleştir + // (audit ORTA #26: aksi halde kullanıcı ham "HTTP 422" görüyordu). + if (body && Array.isArray(body.detail) && body.detail.length) { + const msgs = body.detail.map(e => (e && typeof e.msg === "string" ? e.msg : null)).filter(Boolean); + if (msgs.length) return msgs.join(" · "); + } } catch { // body JSON değil veya parse hatası — generic mesaja düş } diff --git a/frontend/src/lib/labels.js b/frontend/src/lib/labels.js index c638207..e0adf95 100644 --- a/frontend/src/lib/labels.js +++ b/frontend/src/lib/labels.js @@ -19,8 +19,22 @@ export const irrigationStatusLabel = _map({ pending: 'Beklemede', completed: 'Tamamlandı', cancelled: 'İptal', scheduled: 'Planlandı', }); +// Audit fix (#27): sensör tipi — daha önce ham snake_case render ediliyordu. +// charts.js'teki typeLabels'ı yansıtır + field-detail form seçenekleri. +export const sensorTypeLabel = _map({ + soil_moisture: 'Toprak Nemi', soil_temperature: 'Toprak Sıcaklığı', + humidity: 'Hava Nemi', electrical_conductivity: 'Elektriksel İletkenlik', +}); + +// Audit fix (#27): sensör durumu — daha önce ham İngilizce render ediliyordu. +export const sensorStatusLabel = _map({ + active: 'Aktif', inactive: 'Pasif', maintenance: 'Bakımda', faulty: 'Arızalı', +}); + // Hastalık tanısı (ML sınıfları) +// Audit fix (#17): hata durumu teşhisi 'unknown' ham İngilizce sızıyordu (map'te yoktu) → Türkçe karşılık ekle. export const diagnosisLabel = _map({ healthy: 'Sağlıklı', leaf_spot: 'Yaprak lekesi', powdery_mildew: 'Külleme', rust: 'Pas', blight: 'Yanıklık', mosaic_virus: 'Mozaik virüsü', bacterial_wilt: 'Bakteriyel solgunluk', anthracnose: 'Antraknoz', + unknown: 'Belirsiz', }); diff --git a/frontend/src/lib/pages/alerts.js b/frontend/src/lib/pages/alerts.js index 713b104..eb7f1e9 100644 --- a/frontend/src/lib/pages/alerts.js +++ b/frontend/src/lib/pages/alerts.js @@ -26,12 +26,21 @@ export async function loadAlerts() { let qs = 'limit=100'; if (sev) qs += `&severity=${encodeURIComponent(sev)}`; if (resolved) qs += `&is_resolved=${resolved}`; - const list = await api(`/api/alerts/?${qs}`); + // Audit fix (#25): özet kartları FİLTRELİ + 100-capped listeden değil, + // filtreden bağımsız tam listelerden hesaplanır — aksi halde "Kritik"/"Açık" + // kartları aktif filtre altında etiketleriyle çelişiyordu. Kartlar için + // severity/resolved filtresini yok sayan ayrı sorgular kullanılır. + const [list, allList, openList, criticalList] = await Promise.all([ + api(`/api/alerts/?${qs}`), + api('/api/alerts/?limit=500'), + api('/api/alerts/?is_resolved=false&limit=500'), + api('/api/alerts/?severity=critical&limit=500'), + ]); - // Özet kartları - const total = list ? list.length : 0; - const critical = list ? list.filter(a => a.severity === 'critical').length : 0; - const open = list ? list.filter(a => !a.is_resolved).length : 0; + // Özet kartları — filtreden bağımsız tam sayımlar + const total = allList ? allList.length : 0; + const critical = criticalList ? criticalList.length : 0; + const open = openList ? openList.length : 0; cards.innerHTML = `
${total}
Toplam
${open}
Açık
diff --git a/frontend/src/lib/pages/dashboard.js b/frontend/src/lib/pages/dashboard.js index 2aad7f3..101e820 100644 --- a/frontend/src/lib/pages/dashboard.js +++ b/frontend/src/lib/pages/dashboard.js @@ -9,7 +9,7 @@ import { _fmtDate, _fmtNumber, _escAttr, showToast, updateStatus, _STATUS_EMOJI, _STATUS_LABEL } from "../utils.js"; import { api, apiAuth, getAuthToken } from "../api.js"; import { _skeletonCards, _setBusy } from "../skeleton.js"; -import { irrigationStatusLabel, diagnosisLabel } from "../labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "../labels.js"; import { renderMoistureChart, renderTempHumChart, renderPrecipChart } from "../charts.js"; import { getCurrentUser, getApiOnline, setApiOnline } from "../session.js"; import { _getRoleTips } from "../filiz.js"; @@ -32,14 +32,22 @@ function _renderSummaryCards(summary) { : '—'; const irrTitle = irr.field_name ? `${irr.field_name} · ${_fmtDate(irr.scheduled_date)}` : 'Sulama kaydı yok'; const sev = alerts.by_severity || {}; - const severityChips = [ + const _knownSev = [ { key: 'critical', label: 'kritik', cls: 'critical' }, { key: 'medium', label: 'orta', cls: 'medium' }, { key: 'low', label: 'düşük', cls: 'low' }, - ].map(({ key, label, cls }) => { + ]; + // Audit fix (#28): bilinen seviyeler dışındaki severity'ler (örn. 'high') + // sessizce düşüyordu → chip toplamı gerçek total ile uyuşmuyordu. Backend + // sapmış severity'leri koruyor; bilinmeyenleri de sona ekle. + const _knownKeys = new Set(_knownSev.map((s) => s.key)); + const _extraSev = Object.keys(sev) + .filter((k) => !_knownKeys.has(k) && (sev[k] || 0) > 0) + .map((k) => ({ key: k, label: severityLabel(k), cls: _escAttr(k) })); + const severityChips = [..._knownSev, ..._extraSev].map(({ key, label, cls }) => { const cnt = sev[key] || 0; return cnt > 0 - ? `${cnt} ${label}` + ? `${cnt} ${_escAttr(label)}` : ''; }).join(' '); const diseaseDx = diagnosisLabel(disease.diagnosis); @@ -167,6 +175,9 @@ export async function loadDashboard() { if (heroFarms) heroFarms.textContent = summary.farm_count; if (heroSensors) heroSensors.textContent = summary.sensor_count; if (heroReadings) heroReadings.textContent = (summary.soil_moisture_today || {}).reading_count || 0; + // Audit fix (#28): count-up animasyonu veri YAZILDIKTAN sonra tetiklenmeli. + // init()'te '—' placeholder'ı okunduğu için hiç çalışmıyordu (ölü özellik). + animateHeroStats(); } else { cards.innerHTML = `
diff --git a/frontend/src/lib/pages/fields.js b/frontend/src/lib/pages/fields.js index c037a03..0d8fa97 100644 --- a/frontend/src/lib/pages/fields.js +++ b/frontend/src/lib/pages/fields.js @@ -18,8 +18,6 @@ import { navigate } from "../nav.js"; // ─── TARLALARIM (FIELD LIST) ────────────────────────────────── // REBUILD Faz 3 / Adım 6: çiftlik bazlı tarla listesi. // /api/farms/ → her farm için /api/farms/{id} (nested fields). -// Tarlalarım sayfasında çiftlik dropdown'ı için son çekilen çiftlik listesi -let _myFarms = []; export async function loadFields() { const container = document.getElementById('fieldsListContainer'); @@ -39,7 +37,6 @@ export async function loadFields() { _setBusy('fieldsListContainer', false); return; } - _myFarms = farms; // ─── Eylem çubuğu: çiftlik/tarla ekle (toggle formlar) ─── const farmOpts = farms.map(f => ``).join(''); @@ -243,6 +240,13 @@ let currentFieldId = null; export function getCurrentFieldId() { return currentFieldId; } export function openFieldDetail(fieldId) { + // Audit fix (#27): kart tıklaması openFieldDetail'i çağırır + aşağıda hash'i + // set eder → kendi tetiklediği hashchange router üzerinden openFieldDetail'i + // tekrar çağırıyordu (loadFieldDetail 2×). Aynı tarla zaten açıksa (hash de + // eşleşiyorsa) re-entry'yi yut → detay tek kez yüklenir. Doğrudan + // hash/refresh ilk girişte currentFieldId farklı/null olduğu için çalışır. + const _detailActive = document.getElementById('page-field-detail')?.classList.contains('active'); + if (_detailActive && currentFieldId === fieldId && location.hash === `#field/${fieldId}`) return; currentFieldId = fieldId; // Sayfayı aktive et (navigate yerine doğrudan — parametrik route). document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); @@ -323,7 +327,8 @@ export async function analyzeFieldLeaf() { } const data = await resp.json(); const sev = data.severity || 'none'; - const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f97316' : sev === 'low' ? '#eab308' : '#22c55e'; + // Audit fix (#29): 'medium' rengi render.js (#f59e0b) ile hizalandı (önceden #f97316). + const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : sev === 'low' ? '#eab308' : '#22c55e'; const box = document.getElementById('fieldLeafResult'); box.innerHTML = `

🧪 ${_escAttr(diagnosisLabel(data.diagnosis))}

diff --git a/frontend/src/lib/pages/monitoring.js b/frontend/src/lib/pages/monitoring.js index 859ade0..c35c744 100644 --- a/frontend/src/lib/pages/monitoring.js +++ b/frontend/src/lib/pages/monitoring.js @@ -9,7 +9,7 @@ import { _escAttr, showToast } from "../utils.js"; import { api, apiAuth, getAuthToken, API_BASE, _authHeaders } from "../api.js"; import { _skeletonCards, _skeletonRows, _skeletonBlock, _setBusy } from "../skeleton.js"; -import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "../labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel, sensorTypeLabel, sensorStatusLabel } from "../labels.js"; import { charts, renderSensorTypeChart, renderFarmTempChart, renderIrrigationStatusChart, renderNpkRadarChart, renderDailyTrendChart, renderSensorStatsChart, chartTick, chartLegend, chartGrid } from "../charts.js"; @@ -30,18 +30,17 @@ export async function loadSensors(page = 1) { // Table skeleton — 6 rows × 4 columns before the fetch returns. document.getElementById('sensorsTable').innerHTML = _skeletonRows(6, 4); _setBusy('sensorsTable', true); - // Toplam sayiyi her sayfa degisikliginde tekrar cekmek pahali — - // ilk yuklemede al, sonra cache'le. Yeni sensor eklenirse sayfa - // degistiginde tekrar fetch edilir (bilincli trade-off). - if (sensorsTotal === 0) { - const cnt = await api('/api/sensors/count'); - sensorsTotal = cnt?.total || 0; - } + // Toplam sayıyı HER yüklemede tazele: field-detail'den sensör eklenip/silinin + // (monitoring dışı) cache stale kalıyordu (audit ORTA #24). COUNT ucuz query. + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth (api() X-API-Key fallback'i tutarsız). + const cnt = await apiAuth('/api/sensors/count'); + sensorsTotal = cnt?.total || 0; const totalPages = Math.max(1, Math.ceil(sensorsTotal / PAGE_SIZE)); sensorsPage = Math.min(Math.max(1, page), totalPages); const skip = (sensorsPage - 1) * PAGE_SIZE; - const sensors = await api(`/api/sensors/?skip=${skip}&limit=${PAGE_SIZE}`) || []; + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth. + const sensors = await apiAuth(`/api/sensors/?skip=${skip}&limit=${PAGE_SIZE}`) || []; // Nav buton'larini guncelle document.getElementById('sensorsPrevBtn').disabled = sensorsPage <= 1; @@ -57,18 +56,23 @@ export async function loadSensors(page = 1) { // Tabloyu render et — a11y: satira role=button + keyboard handler // EN / TR: satir click + Enter/Space ile detay yuklenir; tabindex=0 ile // keyboard navigation acilir. + // XSS koruması: sensor_type/status kullanıcı-kaynaklı (SensorCreate.sensor_type + // serbest str) → escape şart (audit YÜKSEK stored-XSS). serial_number zaten escape'liydi. + // Audit fix (#27): ham snake_case/İngilizce yerine Türkçe etiket; bilinmeyen + // değerde fallback ham değeri döndürdüğü için _escAttr sarması korunur. document.getElementById('sensorsTable').innerHTML = sensors.map(s => ` - - ${s.id}${s.sensor_type}${_escAttr(s.serial_number)} - ${s.status} + ${s.id}${_escAttr(sensorTypeLabel(s.sensor_type))}${_escAttr(s.serial_number)} + ${_escAttr(sensorStatusLabel(s.status))} `).join(''); _setBusy('sensorsTable', false); } export async function loadSensorDetail(sensorId) { - const readings = await api(`/api/sensors/${sensorId}/readings?limit=30`) || []; + // Audit fix (#10): Bearer-zorunlu endpoint → apiAuth. + const readings = await apiAuth(`/api/sensors/${sensorId}/readings?limit=30`) || []; const sorted = [...readings].reverse(); if (charts.sensorDetail) charts.sensorDetail.destroy(); charts.sensorDetail = new Chart(document.getElementById('sensorDetailChart'), { @@ -99,6 +103,11 @@ export async function loadWeather() {
${temps.length ? Math.min(...temps).toFixed(1) + '—' + Math.max(...temps).toFixed(1) : '—'}°C
Sıcaklık Aralığı
${data.reduce((a,d) => a + (d.precipitation_mm||0), 0).toFixed(1)}mm
Toplam Yağış
`; + } else { + // Audit fix (#23): veri boş/çevrimdışı ise skeleton kartlar sonsuza + // dek dönmesin — "Veri yok" durumu göster. + document.getElementById('weatherCards').innerHTML = + '

🌤️ Hava durumu verisi yok veya sunucuya ulaşılamadı.

'; } _setBusy('weatherCards', false); // Temperature chart @@ -180,13 +189,22 @@ async function _myFieldOptions() { } export async function predictIrrigation() { - const body = { - soil_moisture: +document.getElementById('irr_moisture').value, - soil_temperature: +document.getElementById('irr_soil_temp').value, - humidity: +document.getElementById('irr_humidity').value, - temperature: +document.getElementById('irr_temp').value, - precipitation: +document.getElementById('irr_precip').value, + // Audit fix (#32): boş input'lar unary + ile 0'a dönüşüp model uydurma sıfırlarla + // tahmin yapıyordu → her alanı doğrula, boş/NaN varsa toast ile reddet. + const fields = { + soil_moisture: 'irr_moisture', soil_temperature: 'irr_soil_temp', + humidity: 'irr_humidity', temperature: 'irr_temp', precipitation: 'irr_precip', }; + const body = {}; + for (const [key, id] of Object.entries(fields)) { + const raw = document.getElementById(id).value.trim(); + const num = Number(raw); + if (raw === '' || !Number.isFinite(num)) { + showToast('Tüm alanları geçerli sayılarla doldur', 'warning'); + return; + } + body[key] = num; + } const result = await api('/api/irrigation/predict', { method: 'POST', body: JSON.stringify(body) }); if (result) { _lastIrrigationRec = result.recommended_water_liters; @@ -368,7 +386,8 @@ export async function loadPlants() { for (const r of list) { const sev = r.severity || '—'; const sevColor = sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : sev === 'low' ? '#eab308' : '#22c55e'; - const conf = r.confidence_score ? (r.confidence_score * 100).toFixed(0) + '%' : '—'; + // Audit fix (#18): 0.0 falsy olduğu için '—' görünüyordu → null/undefined kontrolü ile 0% göster. + const conf = r.confidence_score != null ? (r.confidence_score * 100).toFixed(0) + '%' : '—'; const date = r.captured_at ? new Date(r.captured_at).toLocaleDateString('tr-TR') : '—'; html += ` ${date} diff --git a/frontend/src/lib/render.js b/frontend/src/lib/render.js index e2c2677..2c3a91c 100644 --- a/frontend/src/lib/render.js +++ b/frontend/src/lib/render.js @@ -8,7 +8,7 @@ */ import { _escAttr, _fmtDate, _fmtNumber, _STATUS_EMOJI, _STATUS_LABEL } from "./utils.js"; -import { irrigationStatusLabel, diagnosisLabel, severityLabel } from "./labels.js"; +import { irrigationStatusLabel, diagnosisLabel, severityLabel, sensorTypeLabel, sensorStatusLabel } from "./labels.js"; export function renderFieldDetail(d) { const cropName = d.crop ? d.crop.name : 'Ekili bitki yok'; @@ -19,9 +19,12 @@ export function renderFieldDetail(d) { const sensorRows = (d.sensors || []).map(s => { const m = s.latest_moisture_percent != null ? `%${_fmtNumber(s.latest_moisture_percent)}` : '—'; const t = s.latest_soil_temperature_c != null ? `${_fmtNumber(s.latest_soil_temperature_c)}°C` : '—'; - const label = `${s.sensor_type}${s.serial_number ? ' (' + s.serial_number + ')' : ''}`; + // Audit fix (#27): ham snake_case/İngilizce yerine Türkçe etiket; + // bilinmeyen değerde fallback ham değeri döndürdüğü için _escAttr korunur. + // CSS sınıfı (sensor-${status}) ham status'tan üretilmeye devam eder. + const label = `${sensorTypeLabel(s.sensor_type)}${s.serial_number ? ' (' + s.serial_number + ')' : ''}`; return `
-
📡 ${_escAttr(s.sensor_type)} ${_escAttr(s.status)}
+
📡 ${_escAttr(sensorTypeLabel(s.sensor_type))} ${_escAttr(sensorStatusLabel(s.status))}
Nem: ${m} · Toprak: ${t}
${s.latest_reading_at ? _fmtDate(s.latest_reading_at) : 'okuma yok'}
@@ -140,11 +143,13 @@ export function renderPlantResult(data) {

🧪 Sonuç: ${_escAttr(diagnosisLabel(data.diagnosis))}

Güven: %${conf}

Şiddet: ${_escAttr(severityLabel(sev))}

-

Model: ${data.model_version}

+

Model: ${_escAttr(data.model_version)}

Tüm sınıf skorları
    `; + // Audit fix (#31): model_version ve all_scores sınıf anahtarları veri-güdümlü → + // innerHTML'e ham gömmek latent XSS; _escAttr ile kaçır. for (const [cls, score] of Object.entries(data.all_scores || {})) { - html += `
  • ${cls}: ${(score * 100).toFixed(1)}%
  • `; + html += `
  • ${_escAttr(cls)}: ${(score * 100).toFixed(1)}%
  • `; } html += '
'; const box = document.getElementById('plantsResultBox'); diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js index e57db90..373d1e6 100644 --- a/frontend/src/lib/router.js +++ b/frontend/src/lib/router.js @@ -22,6 +22,14 @@ export const pageTitles = { auth: ['Hesabım', 'Profil ve şifre'], }; +// Audit fix (#25): rol-kapısı — sistem-yönetim sayfaları yalnız ilgili rollere. +// index.html nav-item data-role'leriyle birebir aynı: çiftçi #users/#analytics +// kabuğunu hash ile açamasın (veri zaten server-side korunuyor; bu boş kabuğu gizler). +const PAGE_ROLE_GUARD = { + analytics: new Set(['admin', 'overseer', 'developer']), + users: new Set(['admin']), +}; + // Sistem rolleri (developer/overseer/admin) TÜM sistemi görür → çiftçi-odaklı // ("kendi tarlan") alt başlıklar yanıltıcı. Bu sayfalarda kapsam-doğru metin: const SYSTEM_SUBTITLES = { @@ -42,6 +50,15 @@ export function navigate(page, handlers, startHeroTipRotation) { console.warn(`navigate: bilinmeyen sayfa "${page}"`); return; } + // Audit fix (#25): rol-kapısı — yetkisiz kullanıcı sistem-yönetim sayfasının + // kabuğunu açamaz; sessizce dashboard'a yönlendir. Kullanıcı yoksa (init/test) + // kapı uygulanmaz → mevcut akış/testler korunur. + const _user = getCurrentUser(); + const _allowed = PAGE_ROLE_GUARD[page]; + if (_user && _allowed && !_allowed.has(_user.role)) { + if (page !== 'dashboard') navigate('dashboard', handlers, startHeroTipRotation); + return; + } document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item').forEach(n => { n.classList.remove('active'); diff --git a/frontend/src/lib/ui_helpers.js b/frontend/src/lib/ui_helpers.js index 3ded132..0f449fa 100644 --- a/frontend/src/lib/ui_helpers.js +++ b/frontend/src/lib/ui_helpers.js @@ -69,6 +69,12 @@ export async function extractErrorMessage(res) { const body = await res.clone().json(); if (body && typeof body.message === "string" && body.message.trim()) return body.message; if (body && typeof body.detail === "string" && body.detail.trim()) return body.detail; + // Pydantic 422: detail bir dizi ({loc,msg,type}) → alan mesajlarını birleştir + // (audit ORTA #26: aksi halde kullanıcı ham "HTTP 422" görüyordu). + if (body && Array.isArray(body.detail) && body.detail.length) { + const msgs = body.detail.map((e) => (e && typeof e.msg === "string" ? e.msg : null)).filter(Boolean); + if (msgs.length) return msgs.join(" · "); + } } catch { // body JSON değil veya parse hatası — generic mesaja düş } diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index 6923a79..27dc038 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -35,11 +35,14 @@ export function showToast(message, type = 'info', duration = 3500) { const toast = document.createElement('div'); toast.className = `toast ${type}`; const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'; + // message backend hata detayı (örn. dosya adı) olabilir → textContent ile yaz; + // innerHTML interpolasyonu reflected XSS açardı (audit YÜKSEK). toast.innerHTML = ` ${icon} - ${message} + `; + toast.querySelector('.toast-message').textContent = message; container.appendChild(toast); let timer = null; diff --git a/frontend/src/main.js b/frontend/src/main.js index 8187db4..ae615cd 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -18,7 +18,7 @@ import { rethemeCharts } from "./lib/charts.js"; // ─── Sayfa modülleri ───────────────────────────────────────── import { - loadDashboard, loadDemoData, animateHeroStats, _startHeroTipRotation, + loadDashboard, loadDemoData, _startHeroTipRotation, } from "./lib/pages/dashboard.js"; import { loadFields, toggleForm, submitNewFarm, submitNewField, editFarm, deleteFarm, @@ -152,6 +152,10 @@ async function init() { document.addEventListener('click', (e) => { const el = e.target.closest('[data-action]'); if (!el) return; + //