generated from vincent/template-projet
Compare commits
34 Commits
1ff3c15ea9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e91bf6db8d | |||
| 69678fcb06 | |||
| 5afad53aa9 | |||
| 230f018f47 | |||
| 32c6bd56a5 | |||
| 5b870e1eb5 | |||
| 88c3088cc1 | |||
| ae32eabc9c | |||
| 69a3e55aa9 | |||
| 001209d12c | |||
| c8088251dd | |||
| 7cc7ba66d8 | |||
| 3c6356b578 | |||
| 87d2f0add3 | |||
| 4eae30c0e5 | |||
| a39ff7b3b1 | |||
| c535d00947 | |||
| 12cfb49c87 | |||
| 046f542f6a | |||
| f733cd7d5b | |||
| 9ac18ecc8d | |||
| b2b1a3a0a0 | |||
| 03b5022016 | |||
| 87ea0515ad | |||
| b967a0d69b | |||
| d25bc84373 | |||
| 41597c113f | |||
| 3f8d13936d | |||
| 58cb0e314d | |||
| 211fbefd53 | |||
| a0004796ed | |||
| d7719db97d | |||
| 2df14dcc73 | |||
| 0fe1a1b751 |
28
.env.example
28
.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=AuditShield!
|
||||
|
||||
# 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
|
||||
|
||||
@@ -17,15 +17,45 @@ jobs:
|
||||
id: env
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "ENV=prod" >> $GITHUB_OUTPUT
|
||||
echo "COMPOSE_FILE=docker/docker-compose.prod.yml" >> $GITHUB_OUTPUT
|
||||
echo "PROJECT_PATH=/volume1/docker/auditshield-prod" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "ENV=dev" >> $GITHUB_OUTPUT
|
||||
echo "COMPOSE_FILE=docker/docker-compose.yml" >> $GITHUB_OUTPUT
|
||||
echo "PROJECT_PATH=/volume1/docker/auditshield-dev" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.NAS_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/nas_key
|
||||
chmod 600 ~/.ssh/nas_key
|
||||
ssh-keyscan -p 22 ${{ secrets.NAS_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Copy files to NAS
|
||||
run: |
|
||||
ssh -i ~/.ssh/nas_key -o StrictHostKeyChecking=no root@${{ secrets.NAS_HOST }} \
|
||||
"mkdir -p ${{ steps.env.outputs.PROJECT_PATH }}"
|
||||
tar --exclude='.git' --exclude='node_modules' --exclude='.env' -czf - . | \
|
||||
ssh -i ~/.ssh/nas_key -o StrictHostKeyChecking=no root@${{ secrets.NAS_HOST }} \
|
||||
"tar -xzf - -C ${{ steps.env.outputs.PROJECT_PATH }}"
|
||||
|
||||
- name: Setup env file
|
||||
run: |
|
||||
ssh -i ~/.ssh/nas_key -o StrictHostKeyChecking=no root@${{ secrets.NAS_HOST }} \
|
||||
"cat > ${{ steps.env.outputs.PROJECT_PATH }}/.env << 'EOF'
|
||||
SECRET_KEY=${{ secrets.APP_SECRET_KEY }}
|
||||
DEBUG=false
|
||||
POSTGRES_DB=auditshield
|
||||
POSTGRES_USER=auditshield
|
||||
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
|
||||
DATABASE_URL=postgresql://auditshield:${{ secrets.POSTGRES_PASSWORD }}@postgres:5432/auditshield
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
NEXT_PUBLIC_API_URL=https://auditshield.rigolet.tech
|
||||
DOMAIN=auditshield.rigolet.tech
|
||||
TAG=latest
|
||||
EOF"
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
docker compose -f ${{ steps.env.outputs.COMPOSE_FILE }} pull
|
||||
docker compose -f ${{ steps.env.outputs.COMPOSE_FILE }} up -d --remove-orphans
|
||||
docker image prune -f
|
||||
ssh -i ~/.ssh/nas_key -o StrictHostKeyChecking=no root@${{ secrets.NAS_HOST }} \
|
||||
"cd ${{ steps.env.outputs.PROJECT_PATH }} && /usr/local/bin/docker compose -f ${{ steps.env.outputs.COMPOSE_FILE }} up -d --build --remove-orphans && /usr/local/bin/docker image prune -f"
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -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
|
||||
|
||||
93
CLAUDE.md
93
CLAUDE.md
@@ -15,17 +15,33 @@ Outil d'audit infrastructure et sécurité pour clients MSP. Permet de lancer de
|
||||
## Structure
|
||||
```
|
||||
frontend/
|
||||
app/ # Next.js App Router
|
||||
components/ # Composants réutilisables
|
||||
lib/ # Utilitaires, API client
|
||||
app/
|
||||
(auth)/login/ # Page login (pas de layout dashboard)
|
||||
(dashboard)/ # Pages protégées avec sidebar
|
||||
layout.tsx # Guard auth + layout sidebar+main
|
||||
dashboard/ # Stats globales
|
||||
clients/ # CRUD clients
|
||||
audits/ # CRUD audits
|
||||
components/
|
||||
layout/ # Sidebar, Header
|
||||
ui/ # shadcn/ui : Button, Card, Input, Badge, Label
|
||||
lib/
|
||||
api.ts # Client fetch typé (authApi, clientsApi, auditsApi)
|
||||
auth.ts # Helpers JWT localStorage
|
||||
utils.ts # cn() (tailwind-merge + clsx)
|
||||
backend/
|
||||
api/ # Routes FastAPI
|
||||
scanners/ # Orchestration Nmap, OpenVAS, Metasploit, AD
|
||||
models/ # Modèles SQLAlchemy
|
||||
reports/ # Génération PDF
|
||||
api/ # Routes FastAPI (auth, clients, audits)
|
||||
core/ # config.py, database.py, security.py
|
||||
models/ # SQLAlchemy ORM (base.py + TimestampMixin)
|
||||
schemas/ # Pydantic v2 (séparés des modèles)
|
||||
scanners/ # Placeholder Phase 2 (Nmap, OpenVAS...)
|
||||
reports/ # Placeholder Phase 4 (WeasyPrint PDF)
|
||||
tests/ # pytest async avec SQLite in-memory
|
||||
alembic/ # Migrations (env.py configuré)
|
||||
main.py # App FastAPI : CORS, rate limiting, routers
|
||||
docker/
|
||||
docker-compose.yml
|
||||
docker-compose.prod.yml
|
||||
docker-compose.yml # Prod : images registry + labels Traefik (utilisé par Gitea Actions)
|
||||
docker-compose.prod.yml # Alias prod (identique, gardé pour référence)
|
||||
```
|
||||
|
||||
## Commandes
|
||||
@@ -33,17 +49,23 @@ docker/
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
|
||||
# Backend
|
||||
cd backend && pip install -r requirements.txt && uvicorn main:app --reload
|
||||
# Backend (lancer depuis la racine du projet, pas depuis backend/)
|
||||
pip install -r backend/requirements.txt
|
||||
uvicorn backend.main:app --reload
|
||||
|
||||
# Docker complet
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
|
||||
# Migrations BDD
|
||||
cd backend && alembic upgrade head
|
||||
# Migrations BDD (depuis la racine, pas depuis backend/)
|
||||
alembic -c backend/alembic.ini upgrade head
|
||||
# Créer une migration :
|
||||
alembic -c backend/alembic.ini revision --autogenerate -m "description"
|
||||
|
||||
# Tests
|
||||
pytest backend/tests/
|
||||
|
||||
# Typecheck frontend
|
||||
cd frontend && npm run typecheck
|
||||
```
|
||||
|
||||
## Modèle de données
|
||||
@@ -77,8 +99,53 @@ Gitea Actions → Docker → NAS ou VPS selon le client
|
||||
Voir @docker/docker-compose.yml et @.gitea/workflows/deploy.yml
|
||||
|
||||
## Ce que Claude doit savoir sur CE projet
|
||||
|
||||
### Domaine métier
|
||||
- Les scans sont des opérations longues → utiliser des jobs asynchrones (Celery ou BackgroundTasks FastAPI)
|
||||
- Le rapport PDF doit être compréhensible par un dirigeant non-technique
|
||||
- Toujours traduire les CVE en langage clair dans les rapports
|
||||
- La BDD utilise snake_case, le frontend TypeScript utilise camelCase
|
||||
- Les criticités : critique (CVSS 9-10), important (7-8.9), modéré (4-6.9), faible (0-3.9)
|
||||
- Un scan ne peut se lancer que sur des cibles dont `validee = True` (sécurité obligatoire)
|
||||
|
||||
### Conventions de code établies en Phase 1
|
||||
|
||||
**Backend :**
|
||||
- Imports toujours en `backend.xxx` (ex: `from backend.models.user import User`), jamais en relatif — le module s'appelle `backend`
|
||||
- Uvicorn se lance avec `uvicorn backend.main:app`, pas `uvicorn main:app`
|
||||
- Alembic se lance depuis la racine avec `-c backend/alembic.ini`
|
||||
- Les enums Python sont aussi `str` (`class Criticite(str, enum.Enum)`) pour sérialisation JSON automatique
|
||||
- Pydantic v2 : utiliser `model_dump()` (pas `.dict()`), `model_config = {"from_attributes": True}` (pas `orm_mode`)
|
||||
- Pattern PATCH : `payload.model_dump(exclude_unset=True)` pour ne pas écraser les champs non fournis
|
||||
- `get_current_user` est une dépendance FastAPI — toujours l'injecter sur les routes protégées
|
||||
- `selectinload()` pour les relations dans les endpoints "detail" (évite le N+1)
|
||||
- `TimestampMixin` à hériter sur tous les nouveaux modèles
|
||||
- `pool_pre_ping=True` sur l'engine SQLAlchemy pour les connexions longues durée
|
||||
|
||||
**Frontend :**
|
||||
- Les types TypeScript sont en camelCase même si l'API renvoie du snake_case (mapping à faire côté fetch)
|
||||
- `"use client"` obligatoire sur toutes les pages/composants utilisant des hooks React ou des effets
|
||||
- Guard auth dans `(dashboard)/layout.tsx` via `useEffect` + `getToken()` — pas de middleware Next.js
|
||||
- Le client API (`lib/api.ts`) passe toujours le token en paramètre explicite — pas de contexte global
|
||||
- Les nouvelles pages du dashboard vont dans `app/(dashboard)/[section]/page.tsx`
|
||||
- Les nouveaux composants UI shadcn vont dans `components/ui/`, les composants métier dans `components/`
|
||||
- Les rewrites `next.config.mjs` proxifient `/api/*` vers le backend — en dev, définir `NEXT_PUBLIC_API_URL=http://localhost:8000`
|
||||
- JWT stocké dans localStorage (simple pour MVP, à migrer vers HttpOnly cookie plus tard)
|
||||
|
||||
**Infrastructure :**
|
||||
- Le service `backend` dans docker-compose dépend de `postgres` avec `condition: service_healthy`
|
||||
- Le build frontend Next.js utilise `output: "standalone"` → le Dockerfile copie `.next/standalone`
|
||||
- Les variables d'env préfixées `NEXT_PUBLIC_` sont embarquées au build (pas au runtime)
|
||||
- `docker-compose.yml` attend des images pré-buildées depuis `${REGISTRY}` — pas de build local (config prod)
|
||||
- Le `frontend/public/` doit exister (même vide avec `.gitkeep`) — le Dockerfile le copie dans le stage runner
|
||||
- `npm ci` exige un `package-lock.json` commité — toujours commiter le lockfile après `npm install`
|
||||
|
||||
### Pièges à éviter
|
||||
- Ne pas lancer `uvicorn main:app` depuis `backend/` — toujours depuis la racine avec le module complet
|
||||
- Ne pas oublier `"use client"` sur les pages avec `useEffect`, `useState`, `useRouter`
|
||||
- Ne pas utiliser `.dict()` Pydantic v1 — c'est `.model_dump()` en v2
|
||||
- Ne pas faire de scans sans vérifier `cible.validee == True` côté backend
|
||||
- Ne pas commiter `.env` — seulement `.env.example`
|
||||
- Alembic `env.py` importe les modèles depuis `backend.models` — ajouter tout nouveau modèle dans `backend/models/__init__.py`
|
||||
- Ne pas utiliser `next.config.ts` — Next.js 14 ne supporte pas ce format, utiliser `next.config.mjs`
|
||||
- Ne pas utiliser `@radix-ui/react-badge` — ce package n'existe pas sur npm (Badge shadcn = div CVA)
|
||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal 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
41
backend/alembic.ini
Normal 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
48
backend/alembic/env.py
Normal 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()
|
||||
24
backend/alembic/script.py.mako
Normal file
24
backend/alembic/script.py.mako
Normal 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"}
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
191
backend/api/audits.py
Normal file
191
backend/api/audits.py
Normal 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
46
backend/api/auth.py
Normal 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
76
backend/api/clients.py
Normal 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
0
backend/core/__init__.py
Normal file
28
backend/core/config.py
Normal file
28
backend/core/config.py
Normal 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
15
backend/core/database.py
Normal 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
56
backend/core/security.py
Normal 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
39
backend/main.py
Normal 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}
|
||||
21
backend/models/__init__.py
Normal file
21
backend/models/__init__.py
Normal 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
22
backend/models/action.py
Normal 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
30
backend/models/audit.py
Normal 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
16
backend/models/base.py
Normal 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
16
backend/models/client.py
Normal 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
22
backend/models/target.py
Normal 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
14
backend/models/user.py
Normal 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)
|
||||
28
backend/models/vulnerability.py
Normal file
28
backend/models/vulnerability.py
Normal 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")
|
||||
1
backend/reports/__init__.py
Normal file
1
backend/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Génération PDF WeasyPrint — Phase 2
|
||||
17
backend/requirements.txt
Normal file
17
backend/requirements.txt
Normal 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
|
||||
1
backend/scanners/__init__.py
Normal file
1
backend/scanners/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Scanners : Nmap, OpenVAS, Metasploit, AD — Phase 2
|
||||
0
backend/schemas/__init__.py
Normal file
0
backend/schemas/__init__.py
Normal file
99
backend/schemas/audit.py
Normal file
99
backend/schemas/audit.py
Normal 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
30
backend/schemas/client.py
Normal 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
26
backend/schemas/user.py
Normal 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
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
48
backend/tests/test_auth.py
Normal file
48
backend/tests/test_auth.py
Normal 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()
|
||||
73
docker/docker-compose.prod.yml
Normal file
73
docker/docker-compose.prod.yml
Normal 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
|
||||
@@ -1,13 +1,73 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: your-app:latest
|
||||
container_name: your-app
|
||||
restart: unless-stopped
|
||||
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
|
||||
|
||||
27
frontend/Dockerfile
Normal file
27
frontend/Dockerfile
Normal 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"]
|
||||
81
frontend/app/(auth)/login/page.tsx
Normal file
81
frontend/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/app/(dashboard)/audits/page.tsx
Normal file
105
frontend/app/(dashboard)/audits/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/app/(dashboard)/clients/page.tsx
Normal file
107
frontend/app/(dashboard)/clients/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/app/(dashboard)/dashboard/page.tsx
Normal file
120
frontend/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
frontend/app/(dashboard)/layout.tsx
Normal file
23
frontend/app/(dashboard)/layout.tsx
Normal 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
59
frontend/app/globals.css
Normal 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
18
frontend/app/layout.tsx
Normal 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
5
frontend/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
17
frontend/components.json
Normal file
17
frontend/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
frontend/components/layout/header.tsx
Normal file
21
frontend/components/layout/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
frontend/components/layout/sidebar.tsx
Normal file
64
frontend/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
frontend/components/ui/badge.tsx
Normal file
32
frontend/components/ui/badge.tsx
Normal 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 };
|
||||
48
frontend/components/ui/button.tsx
Normal file
48
frontend/components/ui/button.tsx
Normal 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 };
|
||||
55
frontend/components/ui/card.tsx
Normal file
55
frontend/components/ui/card.tsx
Normal 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 };
|
||||
21
frontend/components/ui/input.tsx
Normal file
21
frontend/components/ui/input.tsx
Normal 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 };
|
||||
20
frontend/components/ui/label.tsx
Normal file
20
frontend/components/ui/label.tsx
Normal 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
141
frontend/lib/api.ts
Normal 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
20
frontend/lib/auth.ts
Normal 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
6
frontend/lib/utils.ts
Normal 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));
|
||||
}
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
14
frontend/next.config.mjs
Normal file
14
frontend/next.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://backend:8000"}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7209
frontend/package-lock.json
generated
Normal file
7209
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"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-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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
0
frontend/public/.gitkeep
Normal file
0
frontend/public/.gitkeep
Normal file
73
frontend/tailwind.config.ts
Normal file
73
frontend/tailwind.config.ts
Normal 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
22
frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
211
plan.md
Normal file
211
plan.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# AuditShield — Plan de développement
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Outil d'audit infrastructure et sécurité pour clients MSP.
|
||||
Scans réseau + vulnérabilités + pentest → rapports PDF compréhensibles pour non-techniciens.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 — Socle (terminée)
|
||||
|
||||
> Branche : `feature/phase-1-socle` (PR ouverte, pas encore mergée dans `dev`)
|
||||
|
||||
### Ce qui a été créé
|
||||
|
||||
**Backend (FastAPI + SQLAlchemy)**
|
||||
- `backend/core/` — config (pydantic-settings), database (SQLAlchemy 2.0), security (JWT + bcrypt)
|
||||
- `backend/models/` — 7 modèles : User, Client, Audit, Cible, Vulnérabilité, Action + TimestampMixin
|
||||
- `backend/schemas/` — Pydantic v2 : UserCreate/Read, ClientCreate/Update/Read, AuditCreate/Update/Read/Detail, CibleCreate/Read, VulnerabiliteCreate/Read, ActionCreate/Update/Read
|
||||
- `backend/api/auth.py` — POST /register, POST /login (OAuth2), GET /me
|
||||
- `backend/api/clients.py` — CRUD complet (GET list, POST, GET/{id}, PATCH/{id}, DELETE/{id})
|
||||
- `backend/api/audits.py` — CRUD audit + nested : cibles (add/valider), vulnérabilités (add), actions (add/update)
|
||||
- `backend/main.py` — FastAPI app, CORS (localhost:3000), rate limiting (slowapi), docs /api/docs en DEBUG
|
||||
- `backend/alembic/` — migrations configurées (env.py prêt avec models importés)
|
||||
- `backend/tests/test_auth.py` — test async register + login avec SQLite in-memory
|
||||
|
||||
**Frontend (Next.js 14 App Router)**
|
||||
- Route groups : `(auth)` pour login, `(dashboard)` pour pages protégées
|
||||
- `app/(auth)/login/page.tsx` — formulaire login avec gestion JWT
|
||||
- `app/(dashboard)/layout.tsx` — guard auth (redirect /login), layout sidebar + main
|
||||
- `app/(dashboard)/dashboard/page.tsx` — 4 stat cards + 5 audits récents
|
||||
- `app/(dashboard)/clients/page.tsx` — grille clients avec recherche
|
||||
- `app/(dashboard)/audits/page.tsx` — liste audits avec badges statut + recherche
|
||||
- `components/layout/sidebar.tsx` — navigation (Dashboard, Clients, Audits) + déconnexion
|
||||
- `components/ui/` — Button (CVA variants), Card, Input, Label, Badge (+ variantes criticité)
|
||||
- `lib/api.ts` — client fetch générique typé : authApi, clientsApi, auditsApi
|
||||
- `lib/auth.ts` — helpers localStorage pour token JWT
|
||||
|
||||
**Infrastructure**
|
||||
- `docker/docker-compose.yml` — config prod : images registry + labels Traefik + Let's Encrypt (utilisé par Gitea Actions)
|
||||
- `docker/docker-compose.prod.yml` — alias prod identique (gardé pour référence)
|
||||
- `backend/Dockerfile` — Python 3.11-slim + WeasyPrint deps + alembic upgrade
|
||||
- `frontend/Dockerfile` — multi-stage Node 20 (deps → builder → runner standalone)
|
||||
- `.env.example` — toutes les variables documentées
|
||||
|
||||
### Décisions techniques
|
||||
|
||||
| Décision | Raison |
|
||||
|----------|--------|
|
||||
| SQLAlchemy 2.0 avec type hints Mapped[] | API moderne, meilleure inference TypeScript équivalente côté Python |
|
||||
| Pydantic v2 séparé des modèles ORM | Découplage validation/persistance, évite circular imports |
|
||||
| JWT HS256 + 24h expiration | Simple à implémenter, stateless, suffisant pour MVP |
|
||||
| slowapi pour rate limiting | Compatible FastAPI/Starlette, minimal |
|
||||
| TimestampMixin | DRY pour created_at/updated_at sur tous les modèles |
|
||||
| Cascade delete | Intégrité référentielle (supprimer client → supprime tout) |
|
||||
| Celery + Redis (infra seulement) | Prêt pour les jobs asynchrones de scan (Phase 2) |
|
||||
| Next.js App Router + route groups | Layouts différents auth/dashboard sans duplication |
|
||||
| shadcn/ui + Radix | Accessibilité, composants non-opinionnés, facilement personnalisables |
|
||||
| JWT dans localStorage | Simple pour MVP — à migrer vers HttpOnly cookie en Phase 3 |
|
||||
| camelCase pour types TS, snake_case en DB | Convention JS vs convention Python/SQL |
|
||||
| Rewrites next.config.mjs vers backend | Pas de CORS en prod, tout passe par Next.js |
|
||||
|
||||
### État fonctionnel
|
||||
- ✅ Register/Login utilisateur avec JWT
|
||||
- ✅ CRUD clients complet
|
||||
- ✅ CRUD audits + cibles + vulnérabilités + actions
|
||||
- ✅ Dashboard avec stats temps réel
|
||||
- ✅ Pages liste clients et audits avec recherche
|
||||
- ✅ Guard auth côté frontend
|
||||
- ✅ Docker prod prêt (docker-compose.yml = config prod images registry + Traefik)
|
||||
- ⏳ Pages de formulaire (création/édition) non créées
|
||||
- ⏳ Pages détail client et audit non créées
|
||||
- ⏳ Scanners : dossier vide (placeholder Phase 2)
|
||||
- ⏳ Génération PDF : dossier vide (placeholder Phase 4)
|
||||
|
||||
---
|
||||
|
||||
## 🔲 Phase 2 — Scanners réseau (Nmap)
|
||||
|
||||
> Branche : `feature/phase-2-nmap`
|
||||
|
||||
### Objectif
|
||||
Permettre le lancement d'un scan Nmap sur des cibles validées, afficher les résultats en temps réel, et les persister en base.
|
||||
|
||||
### Tâches backend
|
||||
|
||||
- [ ] **Celery worker** — configurer `backend/celery_app.py`, worker Docker dans docker-compose
|
||||
- [ ] **Scanner Nmap** — `backend/scanners/nmap.py`
|
||||
- Wrapper autour de `python-nmap`
|
||||
- Parse les résultats : ports ouverts, services, OS
|
||||
- Vérification que la cible est `validee = True` avant tout scan
|
||||
- Log de chaque scan en BDD (model `ScanLog`)
|
||||
- [ ] **Model ScanLog** — `backend/models/scan_log.py`
|
||||
- audit_id, cible_id, type_scan, statut, résultat (JSON), durée
|
||||
- [ ] **Tâche Celery** — `backend/scanners/tasks.py`
|
||||
- `task_scan_nmap(audit_id, cible_ids)` — async, met à jour statut
|
||||
- Retour des résultats via polling ou WebSocket
|
||||
- [ ] **Routes scan** — `backend/api/scans.py`
|
||||
- `POST /audits/{audit_id}/scans/nmap` — lance le scan (en background)
|
||||
- `GET /audits/{audit_id}/scans` — liste les scans passés
|
||||
- `GET /audits/{audit_id}/scans/{scan_id}` — détail d'un scan
|
||||
- [ ] **Parsing Nmap → Vulnérabilités** — déduire des vulnérabilités simples (ports dangereux, services obsolètes)
|
||||
|
||||
### Tâches frontend
|
||||
|
||||
- [ ] **Page détail audit** — `app/(dashboard)/audits/[id]/page.tsx`
|
||||
- Affiche : infos audit, liste cibles (avec bouton "Valider"), liste vulnérabilités, liste actions
|
||||
- Bouton "Lancer un scan Nmap"
|
||||
- Statut du scan en temps réel (polling GET /scans)
|
||||
- [ ] **Page détail client** — `app/(dashboard)/clients/[id]/page.tsx`
|
||||
- Infos client + liste des audits du client
|
||||
- Bouton "Nouvel audit"
|
||||
- [ ] **Formulaire nouveau client** — `app/(dashboard)/clients/nouveau/page.tsx`
|
||||
- [ ] **Formulaire nouvel audit** — `app/(dashboard)/audits/nouveau/page.tsx`
|
||||
- [ ] **Formulaire ajout cible** — Modal ou inline dans détail audit
|
||||
- [ ] **Composant ScanStatus** — affiche statut scan (en cours / terminé / erreur)
|
||||
- [ ] **Toast notifications** — feedback utilisateur (succès, erreur)
|
||||
|
||||
### Tâches infra
|
||||
|
||||
- [ ] Ajouter service `worker` dans `docker-compose.yml` (image backend + command: celery worker) — attention, c'est la config prod
|
||||
- [ ] Ajouter `python-nmap` dans `requirements.txt`
|
||||
- [ ] Première migration Alembic : `alembic -c backend/alembic.ini revision --autogenerate -m "init"`
|
||||
|
||||
---
|
||||
|
||||
## 🔲 Phase 3 — Vulnérabilités avancées + Actions
|
||||
|
||||
> Branche : `feature/phase-3-vulnerabilites`
|
||||
|
||||
### Objectif
|
||||
Enrichissement des vulnérabilités avec CVE, scoring CVSS automatique, workflow de gestion des actions, et calcul du score global d'audit.
|
||||
|
||||
### Tâches backend
|
||||
|
||||
- [ ] **Intégration CVE/NVD** — `backend/scanners/cve.py`
|
||||
- Lookup CVE via NVD API (gratuit)
|
||||
- Traduction automatique en langage clair (résumé simplifié)
|
||||
- Auto-remplissage criticité depuis CVSS score
|
||||
- [ ] **Calcul score global audit** — méthode sur le model Audit
|
||||
- Pondération : critique × 4, important × 3, modéré × 2, faible × 1
|
||||
- Normalisé sur 10, mis à jour à chaque vulnérabilité ajoutée
|
||||
- [ ] **OpenVAS optionnel** — `backend/scanners/openvas.py`
|
||||
- Wrapper GVM (Greenbone Vulnerability Management)
|
||||
- Si disponible, scan plus profond
|
||||
- [ ] **Notifications** — model `Notification`, endpoint SSE ou WebSocket
|
||||
- Alertes nouvelles vulnérabilités critiques
|
||||
- Changements de statut d'action
|
||||
|
||||
### Tâches frontend
|
||||
|
||||
- [ ] **Page détail vulnérabilité** — `app/(dashboard)/audits/[id]/vulnerabilites/[vulnId]/page.tsx`
|
||||
- Affiche : titre, criticité (badge coloré), CVE, CVSS, description, recommandation
|
||||
- Liste des actions liées + bouton "Ajouter une action"
|
||||
- Formulaire d'action inline
|
||||
- [ ] **Composant CriticiteBar** — représentation visuelle du score global
|
||||
- [ ] **Page gestion des actions** — tableau kanban simple (Ouvert → En cours → Résolu)
|
||||
- [ ] **Filtres et tri** — sur les listes de vulnérabilités (par criticité, statut)
|
||||
- [ ] **Pagination** — composant `<Pagination>` réutilisable
|
||||
|
||||
---
|
||||
|
||||
## 🔲 Phase 4 — Rapports PDF + Dashboard avancé
|
||||
|
||||
> Branche : `feature/phase-4-rapports`
|
||||
|
||||
### Objectif
|
||||
Génération de rapports PDF professionnels compréhensibles par un dirigeant non-technique, et dashboard enrichi avec graphiques.
|
||||
|
||||
### Tâches backend
|
||||
|
||||
- [ ] **Moteur PDF** — `backend/reports/pdf.py`
|
||||
- WeasyPrint + Jinja2 templates HTML
|
||||
- Template rapport : page de garde, résumé exécutif, score global, vulnérabilités par criticité, recommandations, plan d'actions
|
||||
- Langage client : pas de jargon technique, CVE traduits en langage clair
|
||||
- [ ] **Templates HTML** — `backend/reports/templates/`
|
||||
- `rapport_audit.html` — template principal
|
||||
- `_vulnerabilite.html` — bloc par vulnérabilité
|
||||
- CSS intégré (compatible WeasyPrint)
|
||||
- [ ] **Route export** — `GET /audits/{audit_id}/rapport.pdf`
|
||||
- Génère et streame le PDF
|
||||
- Header `Content-Disposition: attachment`
|
||||
- [ ] **Tâche Celery** — génération PDF asynchrone pour audits longs
|
||||
|
||||
### Tâches frontend
|
||||
|
||||
- [ ] **Bouton "Exporter PDF"** — dans la page détail audit
|
||||
- [ ] **Dashboard avancé** — graphiques :
|
||||
- Répartition vulnérabilités par criticité (donut chart)
|
||||
- Évolution des audits dans le temps (line chart)
|
||||
- Taux de résolution des actions (progress bar)
|
||||
- Utiliser `recharts` ou `chart.js`
|
||||
- [ ] **Page rapports** — `app/(dashboard)/rapports/page.tsx`
|
||||
- Liste des rapports générés, téléchargement
|
||||
- [ ] **Mode impression** — CSS @media print pour la page détail audit
|
||||
|
||||
---
|
||||
|
||||
## Prochaine étape
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git checkout -b feature/phase-2-nmap
|
||||
```
|
||||
|
||||
Commencer par :
|
||||
1. Configurer Celery dans `docker-compose.yml` (service worker)
|
||||
2. Créer `backend/models/scan_log.py`
|
||||
3. Créer `backend/scanners/nmap.py`
|
||||
4. Générer la première migration Alembic
|
||||
5. Créer les pages frontend manquantes (détail audit, formulaires)
|
||||
Reference in New Issue
Block a user