feature/phase-1-socle #1

Merged
vincent merged 3 commits from feature/phase-1-socle into dev 2026-03-21 16:25:40 +00:00
60 changed files with 2308 additions and 6 deletions
Showing only changes of commit 0fe1a1b751 - Show all commits

View File

@@ -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

29
.gitignore vendored
View File

@@ -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

16
backend/Dockerfile Normal file
View File

@@ -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"]

41
backend/alembic.ini Normal file
View File

@@ -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

48
backend/alembic/env.py Normal file
View File

@@ -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()

View File

@@ -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"}

View File

0
backend/api/__init__.py Normal file
View File

191
backend/api/audits.py Normal file
View File

@@ -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

46
backend/api/auth.py Normal file
View File

@@ -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

76
backend/api/clients.py Normal file
View File

@@ -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()

0
backend/core/__init__.py Normal file
View File

28
backend/core/config.py Normal file
View File

@@ -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()

15
backend/core/database.py Normal file
View File

@@ -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()

56
backend/core/security.py Normal file
View File

@@ -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

39
backend/main.py Normal file
View File

@@ -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}

View File

@@ -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",
]

22
backend/models/action.py Normal file
View File

@@ -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")

30
backend/models/audit.py Normal file
View File

@@ -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"
)

16
backend/models/base.py Normal file
View File

@@ -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
)

16
backend/models/client.py Normal file
View File

@@ -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")

22
backend/models/target.py Normal file
View File

@@ -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")

14
backend/models/user.py Normal file
View File

@@ -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)

View File

@@ -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")

View File

@@ -0,0 +1 @@
# Génération PDF WeasyPrint — Phase 2

17
backend/requirements.txt Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
# Scanners : Nmap, OpenVAS, Metasploit, AD — Phase 2

View File

99
backend/schemas/audit.py Normal file
View File

@@ -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] = []

30
backend/schemas/client.py Normal file
View File

@@ -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}

26
backend/schemas/user.py Normal file
View File

@@ -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

View File

View File

@@ -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()

View File

@@ -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

View File

@@ -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

27
frontend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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<string | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-muted/40">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Shield className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl">AuditShield</CardTitle>
<CardDescription>Connectez-vous à votre espace sécurisé</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="vous@exemple.fr"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion..." : "Se connecter"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<string, string> = {
planifie: "Planifié",
en_cours: "En cours",
termine: "Terminé",
annule: "Annulé",
};
const statutVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
planifie: "secondary",
en_cours: "default",
termine: "outline",
annule: "destructive",
};
export default function AuditsPage() {
const [audits, setAudits] = useState<Audit[]>([]);
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 (
<div className="flex flex-col h-full">
<Header title="Audits" />
<div className="flex-1 p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un audit..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button asChild>
<Link href="/audits/nouveau">
<Plus className="h-4 w-4 mr-2" />
Nouvel audit
</Link>
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<ClipboardList className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun audit trouvé</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((audit) => (
<Link key={audit.id} href={`/audits/${audit.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-semibold">{audit.nom}</p>
<p className="text-sm text-muted-foreground">
{audit.dateDebut
? new Date(audit.dateDebut).toLocaleDateString("fr-FR")
: "Date non définie"}
</p>
</div>
<div className="flex items-center gap-3">
{audit.scoreGlobal !== null && (
<span className="text-sm font-medium text-muted-foreground">
Score : {audit.scoreGlobal.toFixed(1)}
</span>
)}
<Badge variant={statutVariant[audit.statut]}>
{statutLabels[audit.statut]}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<Client[]>([]);
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 (
<div className="flex flex-col h-full">
<Header title="Clients" />
<div className="flex-1 p-6 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un client..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button asChild>
<Link href="/clients/nouveau">
<Plus className="h-4 w-4 mr-2" />
Nouveau client
</Link>
</Button>
</div>
{/* Client list */}
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Building2 className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun client trouvé</p>
<p className="text-sm">Créez votre premier client pour commencer.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((client) => (
<Link key={client.id} href={`/clients/${client.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="p-5 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="rounded-full bg-primary/10 p-2">
<Building2 className="h-4 w-4 text-primary" />
</div>
<h3 className="font-semibold">{client.nom}</h3>
</div>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{client.contact && (
<p className="flex items-center gap-1">
<span className="font-medium text-foreground">{client.contact}</span>
</p>
)}
{client.email && (
<p className="flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5" />
{client.email}
</p>
)}
{client.telephone && (
<p className="flex items-center gap-1.5">
<Phone className="h-3.5 w-3.5" />
{client.telephone}
</p>
)}
</div>
<p className="text-xs text-muted-foreground border-t pt-2">
Créé le {new Date(client.createdAt).toLocaleDateString("fr-FR")}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<Stats>({
totalClients: 0,
totalAudits: 0,
auditsEnCours: 0,
auditsTermines: 0,
});
const [recentAudits, setRecentAudits] = useState<Audit[]>([]);
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<string, string> = {
planifie: "Planifié",
en_cours: "En cours",
termine: "Terminé",
annule: "Annulé",
};
const statutColors: Record<string, string> = {
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 (
<div className="flex flex-col h-full">
<Header title="Tableau de bord" />
<div className="flex-1 p-6 space-y-6">
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className={`h-5 w-5 ${color}`} />
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{loading ? <span className="animate-pulse text-muted-foreground"></span> : value}
</p>
</CardContent>
</Card>
))}
</div>
{/* Recent audits */}
<Card>
<CardHeader>
<CardTitle className="text-base">Audits récents</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : recentAudits.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun audit pour le moment.</p>
) : (
<div className="space-y-3">
{recentAudits.map((audit) => (
<div key={audit.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div>
<p className="font-medium text-sm">{audit.nom}</p>
<p className="text-xs text-muted-foreground">
{new Date(audit.createdAt).toLocaleDateString("fr-FR")}
</p>
</div>
<span
className={`text-xs font-medium px-2 py-1 rounded-full ${statutColors[audit.statut]}`}
>
{statutLabels[audit.statut]}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}

59
frontend/app/globals.css Normal file
View File

@@ -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;
}
}

18
frontend/app/layout.tsx Normal file
View File

@@ -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 (
<html lang="fr">
<body className={inter.className}>{children}</body>
</html>
);
}

5
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

17
frontend/components.json Normal file
View File

@@ -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"
}
}

View File

@@ -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 (
<header className="flex items-center justify-between border-b px-6 py-4 bg-background">
<h1 className="text-xl font-semibold">{title}</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
</div>
</header>
);
}

View File

@@ -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 (
<aside className="flex h-screen w-64 flex-col border-r bg-card">
{/* Logo */}
<div className="flex items-center gap-2 px-6 py-5 border-b">
<Shield className="h-7 w-7 text-primary" />
<span className="text-xl font-bold">AuditShield</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
pathname.startsWith(href)
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
);
}

View File

@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@@ -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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

141
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,141 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
type RequestOptions = {
method?: string;
body?: unknown;
token?: string;
};
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, token } = options;
const headers: Record<string, string> = {
"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<User>("/auth/me", { token }),
};
// Clients
export const clientsApi = {
list: (token: string) => request<Client[]>("/clients/", { token }),
get: (id: number, token: string) => request<Client>(`/clients/${id}`, { token }),
create: (data: ClientCreate, token: string) =>
request<Client>("/clients/", { method: "POST", body: data, token }),
update: (id: number, data: Partial<ClientCreate>, token: string) =>
request<Client>(`/clients/${id}`, { method: "PATCH", body: data, token }),
delete: (id: number, token: string) =>
request<void>(`/clients/${id}`, { method: "DELETE", token }),
};
// Audits
export const auditsApi = {
list: (token: string, clientId?: number) =>
request<Audit[]>(`/audits/${clientId ? `?client_id=${clientId}` : ""}`, { token }),
get: (id: number, token: string) => request<AuditDetail>(`/audits/${id}`, { token }),
create: (data: AuditCreate, token: string) =>
request<Audit>("/audits/", { method: "POST", body: data, token }),
update: (id: number, data: Partial<AuditCreate>, token: string) =>
request<Audit>(`/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;
};

20
frontend/lib/auth.ts Normal file
View File

@@ -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();
}

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

15
frontend/next.config.ts Normal file
View File

@@ -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;

45
frontend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -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;

22
frontend/tsconfig.json Normal file
View File

@@ -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"]
}