diff --git a/.env.example b/.env.example index e69de29..0285167 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,28 @@ +# ============================================================================= +# AuditShield — Variables d'environnement +# Copier ce fichier en .env et remplir les valeurs +# NE JAMAIS commiter le fichier .env +# ============================================================================= + +# --- Application --- +SECRET_KEY=changeme-generate-a-strong-random-key-here +DEBUG=false + +# --- Base de données PostgreSQL --- +POSTGRES_DB=auditshield +POSTGRES_USER=auditshield +POSTGRES_PASSWORD=changeme-strong-password + +# Construit automatiquement par docker-compose, à définir manuellement en dev local : +DATABASE_URL=postgresql://auditshield:changeme-strong-password@localhost:5432/auditshield + +# --- Redis / Celery --- +REDIS_URL=redis://redis:6379/0 + +# --- Frontend --- +NEXT_PUBLIC_API_URL=http://localhost:8000 + +# --- Déploiement --- +DOMAIN=auditshield.rigolet.tech +REGISTRY=registry.rigolet.tech +TAG=latest diff --git a/.gitignore b/.gitignore index 45a5122..7ff15a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,30 @@ +# Environment .env -node_modules/ +.env.local +.env.*.local + +# Python __pycache__/ -*.pyc -.DS_Store +*.py[cod] +*.pyo +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +htmlcov/ +.coverage +test.db + +# Node.js +node_modules/ +.next/ +out/ + +# Build dist/ build/ + +# IDE +.vscode/ +.idea/ +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8e813fc --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 libpangoft2-1.0-0 libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN alembic upgrade head || true + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..4377bc3 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql://user:password@localhost/auditshield + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..8db3d53 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,48 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from backend.models import Base +from backend.core.config import settings + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..4940cfd --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/audits.py b/backend/api/audits.py new file mode 100644 index 0000000..d70fd65 --- /dev/null +++ b/backend/api/audits.py @@ -0,0 +1,191 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, selectinload +from backend.core.database import get_db +from backend.core.security import get_current_user +from backend.models.audit import Audit +from backend.models.target import Cible +from backend.models.vulnerability import Vulnerabilite +from backend.models.action import Action +from backend.models.user import User +from backend.schemas.audit import ( + AuditCreate, + AuditUpdate, + AuditRead, + AuditDetail, + CibleCreate, + CibleRead, + VulnerabiliteCreate, + VulnerabiliteRead, + ActionCreate, + ActionUpdate, + ActionRead, +) + +router = APIRouter(prefix="/audits", tags=["audits"]) + + +@router.get("/", response_model=list[AuditRead]) +def list_audits( + client_id: int | None = None, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[Audit]: + q = db.query(Audit) + if client_id: + q = q.filter(Audit.client_id == client_id) + return q.offset(skip).limit(limit).all() + + +@router.post("/", response_model=AuditRead, status_code=status.HTTP_201_CREATED) +def create_audit( + payload: AuditCreate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Audit: + audit = Audit(**payload.model_dump()) + db.add(audit) + db.commit() + db.refresh(audit) + return audit + + +@router.get("/{audit_id}", response_model=AuditDetail) +def get_audit( + audit_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Audit: + audit = ( + db.query(Audit) + .options(selectinload(Audit.cibles), selectinload(Audit.vulnerabilites)) + .filter(Audit.id == audit_id) + .first() + ) + if not audit: + raise HTTPException(status_code=404, detail="Audit introuvable") + return audit + + +@router.patch("/{audit_id}", response_model=AuditRead) +def update_audit( + audit_id: int, + payload: AuditUpdate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Audit: + audit = db.get(Audit, audit_id) + if not audit: + raise HTTPException(status_code=404, detail="Audit introuvable") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(audit, field, value) + + db.commit() + db.refresh(audit) + return audit + + +@router.delete("/{audit_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_audit( + audit_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + audit = db.get(Audit, audit_id) + if not audit: + raise HTTPException(status_code=404, detail="Audit introuvable") + db.delete(audit) + db.commit() + + +# --- Cibles --- + +@router.post("/{audit_id}/cibles", response_model=CibleRead, status_code=status.HTTP_201_CREATED) +def add_cible( + audit_id: int, + payload: CibleCreate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Cible: + if not db.get(Audit, audit_id): + raise HTTPException(status_code=404, detail="Audit introuvable") + cible = Cible(audit_id=audit_id, **payload.model_dump()) + db.add(cible) + db.commit() + db.refresh(cible) + return cible + + +@router.patch("/{audit_id}/cibles/{cible_id}/valider", response_model=CibleRead) +def valider_cible( + audit_id: int, + cible_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Cible: + cible = db.query(Cible).filter(Cible.id == cible_id, Cible.audit_id == audit_id).first() + if not cible: + raise HTTPException(status_code=404, detail="Cible introuvable") + cible.validee = True + db.commit() + db.refresh(cible) + return cible + + +# --- Vulnérabilités --- + +@router.post("/{audit_id}/vulnerabilites", response_model=VulnerabiliteRead, status_code=status.HTTP_201_CREATED) +def add_vulnerabilite( + audit_id: int, + payload: VulnerabiliteCreate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Vulnerabilite: + if not db.get(Audit, audit_id): + raise HTTPException(status_code=404, detail="Audit introuvable") + vuln = Vulnerabilite(audit_id=audit_id, **payload.model_dump()) + db.add(vuln) + db.commit() + db.refresh(vuln) + return vuln + + +# --- Actions --- + +@router.post("/{audit_id}/actions", response_model=ActionRead, status_code=status.HTTP_201_CREATED) +def add_action( + audit_id: int, + payload: ActionCreate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Action: + vuln = db.get(Vulnerabilite, payload.vulnerabilite_id) + if not vuln or vuln.audit_id != audit_id: + raise HTTPException(status_code=404, detail="Vulnérabilité introuvable dans cet audit") + action = Action(**payload.model_dump()) + db.add(action) + db.commit() + db.refresh(action) + return action + + +@router.patch("/{audit_id}/actions/{action_id}", response_model=ActionRead) +def update_action( + audit_id: int, + action_id: int, + payload: ActionUpdate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Action: + action = db.get(Action, action_id) + if not action: + raise HTTPException(status_code=404, detail="Action introuvable") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(action, field, value) + + db.commit() + db.refresh(action) + return action diff --git a/backend/api/auth.py b/backend/api/auth.py new file mode 100644 index 0000000..ac2c480 --- /dev/null +++ b/backend/api/auth.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from backend.core.database import get_db +from backend.core.security import hash_password, verify_password, create_access_token, get_current_user +from backend.models.user import User +from backend.schemas.user import UserCreate, UserRead, Token + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +def register(payload: UserCreate, db: Session = Depends(get_db)) -> User: + if db.query(User).filter(User.email == payload.email).first(): + raise HTTPException(status_code=400, detail="Email déjà utilisé") + + user = User( + email=payload.email, + full_name=payload.full_name, + hashed_password=hash_password(payload.password), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.post("/login", response_model=Token) +def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)) -> Token: + user = db.query(User).filter(User.email == form_data.username).first() + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email ou mot de passe incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException(status_code=403, detail="Compte désactivé") + + token = create_access_token(subject=user.id) + return Token(access_token=token) + + +@router.get("/me", response_model=UserRead) +def me(current_user: User = Depends(get_current_user)) -> User: + return current_user diff --git a/backend/api/clients.py b/backend/api/clients.py new file mode 100644 index 0000000..75270cf --- /dev/null +++ b/backend/api/clients.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from backend.core.database import get_db +from backend.core.security import get_current_user +from backend.models.client import Client +from backend.models.user import User +from backend.schemas.client import ClientCreate, ClientUpdate, ClientRead + +router = APIRouter(prefix="/clients", tags=["clients"]) + + +@router.get("/", response_model=list[ClientRead]) +def list_clients( + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[Client]: + return db.query(Client).offset(skip).limit(limit).all() + + +@router.post("/", response_model=ClientRead, status_code=status.HTTP_201_CREATED) +def create_client( + payload: ClientCreate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Client: + client = Client(**payload.model_dump()) + db.add(client) + db.commit() + db.refresh(client) + return client + + +@router.get("/{client_id}", response_model=ClientRead) +def get_client( + client_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Client: + client = db.get(Client, client_id) + if not client: + raise HTTPException(status_code=404, detail="Client introuvable") + return client + + +@router.patch("/{client_id}", response_model=ClientRead) +def update_client( + client_id: int, + payload: ClientUpdate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Client: + client = db.get(Client, client_id) + if not client: + raise HTTPException(status_code=404, detail="Client introuvable") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(client, field, value) + + db.commit() + db.refresh(client) + return client + + +@router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_client( + client_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> None: + client = db.get(Client, client_id) + if not client: + raise HTTPException(status_code=404, detail="Client introuvable") + db.delete(client) + db.commit() diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..513aeec --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,28 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + # Application + app_name: str = "AuditShield" + debug: bool = False + secret_key: str + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 # 24h + + # Database + database_url: str + + # Redis / Celery + redis_url: str = "redis://redis:6379/0" + + class Config: + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..e7b68d9 --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator +from backend.core.config import settings + +engine = create_engine(settings.database_url, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/core/security.py b/backend/core/security.py new file mode 100644 index 0000000..4296617 --- /dev/null +++ b/backend/core/security.py @@ -0,0 +1,56 @@ +from datetime import datetime, timedelta, timezone +from typing import Any +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, status, Depends +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from backend.core.config import settings +from backend.core.database import get_db + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(subject: Any, expires_delta: timedelta | None = None) -> str: + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) + ) + payload = {"sub": str(subject), "exp": expire} + return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) + + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user( + token: str = Depends(oauth2_scheme), + db: Session = Depends(get_db), +): + from backend.models.user import User + + payload = decode_token(token) + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide") + + user = db.get(User, int(user_id)) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur introuvable ou inactif") + return user diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..c8df41e --- /dev/null +++ b/backend/main.py @@ -0,0 +1,39 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded +from backend.core.config import settings +from backend.api.auth import router as auth_router +from backend.api.clients import router as clients_router +from backend.api.audits import router as audits_router + +limiter = Limiter(key_func=get_remote_address) + +app = FastAPI( + title=settings.app_name, + description="API d'audit infrastructure et sécurité pour MSP", + version="1.0.0", + docs_url="/api/docs" if settings.debug else None, + redoc_url="/api/redoc" if settings.debug else None, +) + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router, prefix="/api") +app.include_router(clients_router, prefix="/api") +app.include_router(audits_router, prefix="/api") + + +@app.get("/api/health") +def health() -> dict: + return {"status": "ok", "service": settings.app_name} diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..be6db34 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1,21 @@ +from backend.models.base import Base +from backend.models.user import User +from backend.models.client import Client +from backend.models.audit import Audit, AuditStatut +from backend.models.target import Cible, CibleType +from backend.models.vulnerability import Vulnerabilite, Criticite +from backend.models.action import Action, ActionStatut + +__all__ = [ + "Base", + "User", + "Client", + "Audit", + "AuditStatut", + "Cible", + "CibleType", + "Vulnerabilite", + "Criticite", + "Action", + "ActionStatut", +] diff --git a/backend/models/action.py b/backend/models/action.py new file mode 100644 index 0000000..a289884 --- /dev/null +++ b/backend/models/action.py @@ -0,0 +1,22 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Text, ForeignKey, Enum as SAEnum +import enum +from backend.models.base import Base, TimestampMixin + + +class ActionStatut(str, enum.Enum): + ouvert = "ouvert" + en_cours = "en_cours" + resolu = "resolu" + + +class Action(Base, TimestampMixin): + __tablename__ = "actions" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + vulnerabilite_id: Mapped[int] = mapped_column(ForeignKey("vulnerabilites.id"), nullable=False, index=True) + statut: Mapped[ActionStatut] = mapped_column(SAEnum(ActionStatut), default=ActionStatut.ouvert, nullable=False) + assigne_a: Mapped[str | None] = mapped_column(String(255), nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + + vulnerabilite: Mapped["Vulnerabilite"] = relationship("Vulnerabilite", back_populates="actions") diff --git a/backend/models/audit.py b/backend/models/audit.py new file mode 100644 index 0000000..cdf966e --- /dev/null +++ b/backend/models/audit.py @@ -0,0 +1,30 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Integer, Float, ForeignKey, DateTime, Enum as SAEnum +from datetime import datetime +import enum +from backend.models.base import Base, TimestampMixin + + +class AuditStatut(str, enum.Enum): + planifie = "planifie" + en_cours = "en_cours" + termine = "termine" + annule = "annule" + + +class Audit(Base, TimestampMixin): + __tablename__ = "audits" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True) + nom: Mapped[str] = mapped_column(String(255), nullable=False) + statut: Mapped[AuditStatut] = mapped_column(SAEnum(AuditStatut), default=AuditStatut.planifie, nullable=False) + date_debut: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + date_fin: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + score_global: Mapped[float | None] = mapped_column(Float, nullable=True) + + client: Mapped["Client"] = relationship("Client", back_populates="audits") + cibles: Mapped[list["Cible"]] = relationship("Cible", back_populates="audit", cascade="all, delete-orphan") + vulnerabilites: Mapped[list["Vulnerabilite"]] = relationship( + "Vulnerabilite", back_populates="audit", cascade="all, delete-orphan" + ) diff --git a/backend/models/base.py b/backend/models/base.py new file mode 100644 index 0000000..c708f61 --- /dev/null +++ b/backend/models/base.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped +from sqlalchemy import DateTime, func +from datetime import datetime + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/backend/models/client.py b/backend/models/client.py new file mode 100644 index 0000000..678a197 --- /dev/null +++ b/backend/models/client.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Text +from backend.models.base import Base, TimestampMixin + + +class Client(Base, TimestampMixin): + __tablename__ = "clients" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + nom: Mapped[str] = mapped_column(String(255), nullable=False) + contact: Mapped[str | None] = mapped_column(String(255), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + telephone: Mapped[str | None] = mapped_column(String(50), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + audits: Mapped[list["Audit"]] = relationship("Audit", back_populates="client", cascade="all, delete-orphan") diff --git a/backend/models/target.py b/backend/models/target.py new file mode 100644 index 0000000..ee38188 --- /dev/null +++ b/backend/models/target.py @@ -0,0 +1,22 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, ForeignKey, Enum as SAEnum +import enum +from backend.models.base import Base, TimestampMixin + + +class CibleType(str, enum.Enum): + ip = "ip" + domaine = "domaine" + subnet = "subnet" + + +class Cible(Base, TimestampMixin): + __tablename__ = "cibles" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + audit_id: Mapped[int] = mapped_column(ForeignKey("audits.id"), nullable=False, index=True) + type: Mapped[CibleType] = mapped_column(SAEnum(CibleType), nullable=False) + valeur: Mapped[str] = mapped_column(String(255), nullable=False) + validee: Mapped[bool] = mapped_column(default=False, nullable=False) + + audit: Mapped["Audit"] = relationship("Audit", back_populates="cibles") diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..92a8a14 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,14 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Boolean +from backend.models.base import Base, TimestampMixin + + +class User(Base, TimestampMixin): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) diff --git a/backend/models/vulnerability.py b/backend/models/vulnerability.py new file mode 100644 index 0000000..f0e4299 --- /dev/null +++ b/backend/models/vulnerability.py @@ -0,0 +1,28 @@ +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Text, Float, ForeignKey, Enum as SAEnum +import enum +from backend.models.base import Base, TimestampMixin + + +class Criticite(str, enum.Enum): + critique = "critique" # CVSS 9-10 + important = "important" # CVSS 7-8.9 + modere = "modere" # CVSS 4-6.9 + faible = "faible" # CVSS 0-3.9 + + +class Vulnerabilite(Base, TimestampMixin): + __tablename__ = "vulnerabilites" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + audit_id: Mapped[int] = mapped_column(ForeignKey("audits.id"), nullable=False, index=True) + criticite: Mapped[Criticite] = mapped_column(SAEnum(Criticite), nullable=False) + titre: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + recommandation: Mapped[str] = mapped_column(Text, nullable=False) + cve: Mapped[str | None] = mapped_column(String(50), nullable=True) + cvss_score: Mapped[float | None] = mapped_column(Float, nullable=True) + cible: Mapped[str | None] = mapped_column(String(255), nullable=True) + + audit: Mapped["Audit"] = relationship("Audit", back_populates="vulnerabilites") + actions: Mapped[list["Action"]] = relationship("Action", back_populates="vulnerabilite", cascade="all, delete-orphan") diff --git a/backend/reports/__init__.py b/backend/reports/__init__.py new file mode 100644 index 0000000..0b32f9b --- /dev/null +++ b/backend/reports/__init__.py @@ -0,0 +1 @@ +# Génération PDF WeasyPrint — Phase 2 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..448d3e8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +psycopg2-binary==2.9.9 +pydantic[email]==2.7.1 +pydantic-settings==2.2.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +python-dotenv==1.0.1 +weasyprint==62.3 +slowapi==0.1.9 +pytest==8.2.0 +httpx==0.27.0 +celery==5.4.0 +redis==5.0.4 diff --git a/backend/scanners/__init__.py b/backend/scanners/__init__.py new file mode 100644 index 0000000..3cc4615 --- /dev/null +++ b/backend/scanners/__init__.py @@ -0,0 +1 @@ +# Scanners : Nmap, OpenVAS, Metasploit, AD — Phase 2 diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/schemas/audit.py b/backend/schemas/audit.py new file mode 100644 index 0000000..a7ff41f --- /dev/null +++ b/backend/schemas/audit.py @@ -0,0 +1,99 @@ +from pydantic import BaseModel +from datetime import datetime +from backend.models.audit import AuditStatut +from backend.models.target import CibleType +from backend.models.vulnerability import Criticite +from backend.models.action import ActionStatut + + +class CibleCreate(BaseModel): + type: CibleType + valeur: str + + +class CibleRead(BaseModel): + id: int + audit_id: int + type: CibleType + valeur: str + validee: bool + + model_config = {"from_attributes": True} + + +class VulnerabiliteCreate(BaseModel): + criticite: Criticite + titre: str + description: str + recommandation: str + cve: str | None = None + cvss_score: float | None = None + cible: str | None = None + + +class VulnerabiliteRead(BaseModel): + id: int + audit_id: int + criticite: Criticite + titre: str + description: str + recommandation: str + cve: str | None + cvss_score: float | None + cible: str | None + + model_config = {"from_attributes": True} + + +class ActionCreate(BaseModel): + vulnerabilite_id: int + assigne_a: str | None = None + note: str | None = None + + +class ActionUpdate(BaseModel): + statut: ActionStatut | None = None + assigne_a: str | None = None + note: str | None = None + + +class ActionRead(BaseModel): + id: int + vulnerabilite_id: int + statut: ActionStatut + assigne_a: str | None + note: str | None + + model_config = {"from_attributes": True} + + +class AuditCreate(BaseModel): + client_id: int + nom: str + date_debut: datetime | None = None + + +class AuditUpdate(BaseModel): + nom: str | None = None + statut: AuditStatut | None = None + date_debut: datetime | None = None + date_fin: datetime | None = None + score_global: float | None = None + + +class AuditRead(BaseModel): + id: int + client_id: int + nom: str + statut: AuditStatut + date_debut: datetime | None + date_fin: datetime | None + score_global: float | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class AuditDetail(AuditRead): + cibles: list[CibleRead] = [] + vulnerabilites: list[VulnerabiliteRead] = [] diff --git a/backend/schemas/client.py b/backend/schemas/client.py new file mode 100644 index 0000000..2e9149b --- /dev/null +++ b/backend/schemas/client.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, EmailStr +from datetime import datetime + + +class ClientCreate(BaseModel): + nom: str + contact: str | None = None + email: str | None = None + telephone: str | None = None + notes: str | None = None + + +class ClientUpdate(BaseModel): + nom: str | None = None + contact: str | None = None + email: str | None = None + telephone: str | None = None + notes: str | None = None + + +class ClientRead(BaseModel): + id: int + nom: str + contact: str | None + email: str | None + telephone: str | None + notes: str | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/schemas/user.py b/backend/schemas/user.py new file mode 100644 index 0000000..eb97114 --- /dev/null +++ b/backend/schemas/user.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, EmailStr + + +class UserCreate(BaseModel): + email: EmailStr + full_name: str + password: str + + +class UserRead(BaseModel): + id: int + email: str + full_name: str + is_active: bool + is_admin: bool + + model_config = {"from_attributes": True} + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + user_id: int diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..8027dcc --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,48 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from backend.main import app +from backend.models.base import Base +from backend.core.database import get_db + +TEST_DATABASE_URL = "sqlite:///./test.db" + +engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False}) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def override_get_db(): + db = TestingSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(autouse=True) +def setup_db(): + Base.metadata.create_all(bind=engine) + app.dependency_overrides[get_db] = override_get_db + yield + Base.metadata.drop_all(bind=engine) + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_register_and_login(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.post("/api/auth/register", json={ + "email": "test@example.com", + "full_name": "Test User", + "password": "secret123", + }) + assert r.status_code == 201 + assert r.json()["email"] == "test@example.com" + + r = await client.post("/api/auth/login", data={ + "username": "test@example.com", + "password": "secret123", + }) + assert r.status_code == 200 + assert "access_token" in r.json() diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 0000000..5593d96 --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,73 @@ +version: "3.8" + +services: + postgres: + image: postgres:16-alpine + container_name: auditshield-db-prod + restart: always + environment: + POSTGRES_DB: ${POSTGRES_DB:-auditshield} + POSTGRES_USER: ${POSTGRES_USER:-auditshield} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + volumes: + - postgres_data_prod:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-auditshield}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + + redis: + image: redis:7-alpine + container_name: auditshield-redis-prod + restart: always + networks: + - internal + + backend: + image: ${REGISTRY}/auditshield-backend:${TAG:-latest} + container_name: auditshield-backend-prod + restart: always + env_file: .env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-auditshield}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-auditshield} + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + networks: + - internal + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.auditshield-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)" + - "traefik.http.routers.auditshield-api.entrypoints=websecure" + - "traefik.http.routers.auditshield-api.tls.certresolver=letsencrypt" + + frontend: + image: ${REGISTRY}/auditshield-frontend:${TAG:-latest} + container_name: auditshield-frontend-prod + restart: always + environment: + NEXT_PUBLIC_API_URL: "" + depends_on: + - backend + networks: + - internal + - proxy + labels: + - "traefik.enable=true" + - "traefik.http.routers.auditshield.rule=Host(`${DOMAIN}`)" + - "traefik.http.routers.auditshield.entrypoints=websecure" + - "traefik.http.routers.auditshield.tls.certresolver=letsencrypt" + +volumes: + postgres_data_prod: + +networks: + internal: + driver: bridge + proxy: + external: true diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1de6776..2dcbb5c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,13 +1,69 @@ version: "3.8" services: - app: - image: your-app:latest - container_name: your-app + postgres: + image: postgres:16-alpine + container_name: auditshield-db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-auditshield} + POSTGRES_USER: ${POSTGRES_USER:-auditshield} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-auditshield}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - internal + + redis: + image: redis:7-alpine + container_name: auditshield-redis restart: unless-stopped networks: + - internal + + backend: + build: + context: ../backend + dockerfile: Dockerfile + container_name: auditshield-backend + restart: unless-stopped + env_file: ../.env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-auditshield}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-auditshield} + REDIS_URL: redis://redis:6379/0 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_started + networks: + - internal - proxy + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + container_name: auditshield-frontend + restart: unless-stopped + environment: + NEXT_PUBLIC_API_URL: "" + depends_on: + - backend + networks: + - internal + - proxy + +volumes: + postgres_data: + networks: + internal: + driver: bridge proxy: external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..35a70f3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 + +CMD ["node", "server.js"] diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ef73dc2 --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState, FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Shield } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { authApi } from "@/lib/api"; +import { setToken } from "@/lib/auth"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const { access_token } = await authApi.login(email, password); + setToken(access_token); + router.push("/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Erreur de connexion"); + } finally { + setLoading(false); + } + } + + return ( +
+ + +
+ +
+ AuditShield + Connectez-vous à votre espace sécurisé +
+ +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ {error && ( +

{error}

+ )} + +
+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/audits/page.tsx b/frontend/app/(dashboard)/audits/page.tsx new file mode 100644 index 0000000..0bfc3b7 --- /dev/null +++ b/frontend/app/(dashboard)/audits/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Plus, Search, ClipboardList } from "lucide-react"; +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { auditsApi, type Audit } from "@/lib/api"; +import { getToken } from "@/lib/auth"; +import Link from "next/link"; + +const statutLabels: Record = { + planifie: "Planifié", + en_cours: "En cours", + termine: "Terminé", + annule: "Annulé", +}; + +const statutVariant: Record = { + planifie: "secondary", + en_cours: "default", + termine: "outline", + annule: "destructive", +}; + +export default function AuditsPage() { + const [audits, setAudits] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = getToken(); + if (!token) return; + auditsApi.list(token).then(setAudits).finally(() => setLoading(false)); + }, []); + + const filtered = audits.filter((a) => + a.nom.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+
+
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {loading ? ( +

Chargement...

+ ) : filtered.length === 0 ? ( +
+ +

Aucun audit trouvé

+
+ ) : ( +
+ {filtered.map((audit) => ( + + + +
+

{audit.nom}

+

+ {audit.dateDebut + ? new Date(audit.dateDebut).toLocaleDateString("fr-FR") + : "Date non définie"} +

+
+
+ {audit.scoreGlobal !== null && ( + + Score : {audit.scoreGlobal.toFixed(1)} + + )} + + {statutLabels[audit.statut]} + +
+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/clients/page.tsx b/frontend/app/(dashboard)/clients/page.tsx new file mode 100644 index 0000000..f2fcdd9 --- /dev/null +++ b/frontend/app/(dashboard)/clients/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Plus, Search, Building2, Mail, Phone } from "lucide-react"; +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { clientsApi, type Client } from "@/lib/api"; +import { getToken } from "@/lib/auth"; +import Link from "next/link"; + +export default function ClientsPage() { + const [clients, setClients] = useState([]); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = getToken(); + if (!token) return; + clientsApi.list(token).then(setClients).finally(() => setLoading(false)); + }, []); + + const filtered = clients.filter((c) => + c.nom.toLowerCase().includes(search.toLowerCase()) || + c.contact?.toLowerCase().includes(search.toLowerCase()) || + c.email?.toLowerCase().includes(search.toLowerCase()) + ); + + return ( +
+
+
+ {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {/* Client list */} + {loading ? ( +

Chargement...

+ ) : filtered.length === 0 ? ( +
+ +

Aucun client trouvé

+

Créez votre premier client pour commencer.

+
+ ) : ( +
+ {filtered.map((client) => ( + + + +
+
+
+ +
+

{client.nom}

+
+
+
+ {client.contact && ( +

+ {client.contact} +

+ )} + {client.email && ( +

+ + {client.email} +

+ )} + {client.telephone && ( +

+ + {client.telephone} +

+ )} +
+

+ Créé le {new Date(client.createdAt).toLocaleDateString("fr-FR")} +

+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..7276a9b --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Users, ClipboardList, ShieldAlert, CheckCircle } from "lucide-react"; +import { Header } from "@/components/layout/header"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { clientsApi, auditsApi, type Client, type Audit } from "@/lib/api"; +import { getToken } from "@/lib/auth"; + +type Stats = { + totalClients: number; + totalAudits: number; + auditsEnCours: number; + auditsTermines: number; +}; + +export default function DashboardPage() { + const [stats, setStats] = useState({ + totalClients: 0, + totalAudits: 0, + auditsEnCours: 0, + auditsTermines: 0, + }); + const [recentAudits, setRecentAudits] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = getToken(); + if (!token) return; + + Promise.all([clientsApi.list(token), auditsApi.list(token)]) + .then(([clients, audits]) => { + setStats({ + totalClients: clients.length, + totalAudits: audits.length, + auditsEnCours: audits.filter((a) => a.statut === "en_cours").length, + auditsTermines: audits.filter((a) => a.statut === "termine").length, + }); + setRecentAudits(audits.slice(0, 5)); + }) + .finally(() => setLoading(false)); + }, []); + + const statCards = [ + { label: "Clients", value: stats.totalClients, icon: Users, color: "text-blue-600" }, + { label: "Audits total", value: stats.totalAudits, icon: ClipboardList, color: "text-purple-600" }, + { label: "En cours", value: stats.auditsEnCours, icon: ShieldAlert, color: "text-orange-500" }, + { label: "Terminés", value: stats.auditsTermines, icon: CheckCircle, color: "text-green-600" }, + ]; + + const statutLabels: Record = { + planifie: "Planifié", + en_cours: "En cours", + termine: "Terminé", + annule: "Annulé", + }; + + const statutColors: Record = { + planifie: "text-blue-600 bg-blue-50", + en_cours: "text-orange-600 bg-orange-50", + termine: "text-green-600 bg-green-50", + annule: "text-gray-500 bg-gray-50", + }; + + return ( +
+
+
+ {/* Stats cards */} +
+ {statCards.map(({ label, value, icon: Icon, color }) => ( + + + {label} + + + +

+ {loading ? : value} +

+
+
+ ))} +
+ + {/* Recent audits */} + + + Audits récents + + + {loading ? ( +

Chargement...

+ ) : recentAudits.length === 0 ? ( +

Aucun audit pour le moment.

+ ) : ( +
+ {recentAudits.map((audit) => ( +
+
+

{audit.nom}

+

+ {new Date(audit.createdAt).toLocaleDateString("fr-FR")} +

+
+ + {statutLabels[audit.statut]} + +
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..5104a26 --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Sidebar } from "@/components/layout/sidebar"; +import { getToken } from "@/lib/auth"; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + + useEffect(() => { + if (!getToken()) { + router.replace("/login"); + } + }, [router]); + + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..65f16c6 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "AuditShield", + description: "Plateforme d'audit infrastructure et sécurité", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..c3a1c90 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/dashboard"); +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..fa674c9 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx new file mode 100644 index 0000000..503c41f --- /dev/null +++ b/frontend/components/layout/header.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { Bell } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface HeaderProps { + title: string; +} + +export function Header({ title }: HeaderProps) { + return ( +
+

{title}

+
+ +
+
+ ); +} diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx new file mode 100644 index 0000000..8b2edc7 --- /dev/null +++ b/frontend/components/layout/sidebar.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Shield, Users, ClipboardList, LayoutDashboard, LogOut } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { removeToken } from "@/lib/auth"; +import { useRouter } from "next/navigation"; + +const navItems = [ + { href: "/dashboard", label: "Tableau de bord", icon: LayoutDashboard }, + { href: "/clients", label: "Clients", icon: Users }, + { href: "/audits", label: "Audits", icon: ClipboardList }, +]; + +export function Sidebar() { + const pathname = usePathname(); + const router = useRouter(); + + function handleLogout() { + removeToken(); + router.push("/login"); + } + + return ( + + ); +} diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..75b0309 --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + critique: "border-transparent bg-red-600 text-white", + important: "border-transparent bg-orange-500 text-white", + modere: "border-transparent bg-yellow-500 text-white", + faible: "border-transparent bg-green-500 text-white", + }, + }, + defaultVariants: { variant: "default" }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..b16205e --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx new file mode 100644 index 0000000..550babb --- /dev/null +++ b/frontend/components/ui/card.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..4a7bff4 --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = "Input"; + +export { Input }; diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx new file mode 100644 index 0000000..6ac6d6a --- /dev/null +++ b/frontend/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..938d6b1 --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,141 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ""; + +type RequestOptions = { + method?: string; + body?: unknown; + token?: string; +}; + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = "GET", body, token } = options; + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + const res = await fetch(`${API_BASE}/api${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Erreur réseau" })); + throw new Error(error.detail ?? "Erreur inconnue"); + } + + if (res.status === 204) return undefined as T; + return res.json(); +} + +// Auth +export const authApi = { + login: (email: string, password: string) => { + const form = new URLSearchParams({ username: email, password }); + return fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }).then((r) => { + if (!r.ok) throw new Error("Identifiants incorrects"); + return r.json() as Promise<{ access_token: string; token_type: string }>; + }); + }, + me: (token: string) => request("/auth/me", { token }), +}; + +// Clients +export const clientsApi = { + list: (token: string) => request("/clients/", { token }), + get: (id: number, token: string) => request(`/clients/${id}`, { token }), + create: (data: ClientCreate, token: string) => + request("/clients/", { method: "POST", body: data, token }), + update: (id: number, data: Partial, token: string) => + request(`/clients/${id}`, { method: "PATCH", body: data, token }), + delete: (id: number, token: string) => + request(`/clients/${id}`, { method: "DELETE", token }), +}; + +// Audits +export const auditsApi = { + list: (token: string, clientId?: number) => + request(`/audits/${clientId ? `?client_id=${clientId}` : ""}`, { token }), + get: (id: number, token: string) => request(`/audits/${id}`, { token }), + create: (data: AuditCreate, token: string) => + request("/audits/", { method: "POST", body: data, token }), + update: (id: number, data: Partial, token: string) => + request(`/audits/${id}`, { method: "PATCH", body: data, token }), +}; + +// Types +export type User = { + id: number; + email: string; + fullName: string; + isActive: boolean; + isAdmin: boolean; +}; + +export type Client = { + id: number; + nom: string; + contact: string | null; + email: string | null; + telephone: string | null; + notes: string | null; + createdAt: string; +}; + +export type ClientCreate = { + nom: string; + contact?: string; + email?: string; + telephone?: string; + notes?: string; +}; + +export type Audit = { + id: number; + clientId: number; + nom: string; + statut: "planifie" | "en_cours" | "termine" | "annule"; + dateDebut: string | null; + dateFin: string | null; + scoreGlobal: number | null; + createdAt: string; +}; + +export type AuditCreate = { + clientId: number; + nom: string; + dateDebut?: string; +}; + +export type AuditDetail = Audit & { + cibles: Cible[]; + vulnerabilites: Vulnerabilite[]; +}; + +export type Cible = { + id: number; + auditId: number; + type: "ip" | "domaine" | "subnet"; + valeur: string; + validee: boolean; +}; + +export type Vulnerabilite = { + id: number; + auditId: number; + criticite: "critique" | "important" | "modere" | "faible"; + titre: string; + description: string; + recommandation: string; + cve: string | null; + cvssScore: number | null; + cible: string | null; +}; diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts new file mode 100644 index 0000000..42befd1 --- /dev/null +++ b/frontend/lib/auth.ts @@ -0,0 +1,20 @@ +"use client"; + +const TOKEN_KEY = "auditshield_token"; + +export function getToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export function isAuthenticated(): boolean { + return !!getToken(); +} diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/frontend/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..e1ce369 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://backend:8000"}/api/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..dac449a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "auditshield-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next": "14.2.3", + "react": "^18", + "react-dom": "^18", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-badge": "^1.0.0", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.378.0", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "jose": "^5.2.4", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@types/js-cookie": "^3.0.6", + "typescript": "^5", + "tailwindcss": "^3.4.1", + "postcss": "^8", + "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.2.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..88abe35 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,73 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./lib/**/*.{ts,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { "2xl": "1400px" }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..522b6a0 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}