Compare commits

...
9 Commits
Author SHA1 Message Date
bruno 4c4b1222d5 chore: ignore les diagrammes E2E générés (excalidraw)
CI / lint (push) Successful in 2m23s
CI / security (push) Failing after 1m48s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 2m28s
CI / e2e (push) Successful in 14m5s
2026-09-27 14:46:09 -04:00
bruno a3973b981c securite: #87 T6-T8 fin dette — deps qualifiées, semgrep, Secure auto, CORS 2026-09-27 12:43:31 -04:00
bruno b6e2029770 fix: E2E node-direct, timeouts réalistes 25/30 min (complément BUG-080)
CI / lint (push) Successful in 2m22s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m48s
CI / build (push) Successful in 1m33s
CI / e2e (push) Successful in 14m3s
2026-09-27 11:28:35 -04:00
bruno 6b878caff3 securite: #87 T5c script-src sans unsafe-inline (nonces T5b)
CI / lint (push) Successful in 2m18s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 1m34s
CI / e2e (push) Successful in 14m22s
2026-09-27 10:36:00 -04:00
bruno e9b7a317c1 fix: mfa/status 200 auth désactivée (garde anonymous) BUG-081 + clôture BUG-080/082/083 2026-09-27 10:35:33 -04:00
bruno 14b8032635 fix: CI lint — config-ai-keys.test.mjs rejoint l'étape JSDOM (complément BUG-082)
CI / security (push) Successful in 1m37s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 4m8s
CI / build (push) Successful in 1m38s
CI / e2e (push) Successful in 14m28s
2026-09-27 09:44:18 -04:00
bruno 7dfe26c83d fix: CI security — echo pip-audit sans dièse (runner Act) BUG-083
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m37s
2026-09-27 09:37:34 -04:00
bruno 24229316c7 fix: CI lint — upload.test.mjs rejoint l'étape JSDOM (jsdom) BUG-082
CI / lint (push) Failing after 1m30s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:23:51 -04:00
bruno 7d70e0fb75 fix: harnais E2E local anti-blocage BUG-080
CI / lint (push) Failing after 1m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1m33s
2026-09-27 09:08:58 -04:00
31 changed files with 891 additions and 84 deletions
+6 -5
View File
@@ -12,11 +12,12 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local.
# OBSIGATE_ALLOW_INSECURE=false
# Sécurité des cookies (activer si derrière HTTPS)
# false par défaut : les navigateurs ignorent les cookies `Secure` en HTTP,
# ce qui casserait les logins en local. En production (TLS + bind réseau),
# posez true — un avertissement est loggé au démarrage sinon (#87).
# OBSIGATE_SECURE_COOKIES=false
# Sécurité des cookies : true|false|auto (défaut : auto — Secure si la
# requête arrive en https, sinon pas de flag ; les navigateurs ignorent les
# cookies `Secure` en HTTP, ce qui casserait les logins en local).
# Derrière un reverse proxy qui termine TLS, auto suffit avec
# OBSIGATE_TRUST_PROXY=true (X-Forwarded-Proto honoré).
# OBSIGATE_SECURE_COOKIES=auto
# Tokens TTL en secondes
# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
+21 -9
View File
@@ -44,13 +44,11 @@ jobs:
node tests/frontend/config-mobile.test.mjs
node tests/frontend/settings-order-avatar.test.mjs
node tests/frontend/mobile-toolbar.test.mjs
node tests/frontend/upload.test.mjs
node tests/frontend/pretty.test.mjs
node tests/frontend/media-viewer.test.mjs
node tests/frontend/mfa-settings.test.mjs
node tests/frontend/config-ai-keys.test.mjs
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition)
- name: Frontend JSDOM tests (PaneManager + Excalidraw + Plugins + AI + SW + Collab + Mobile + Semantic + Desktop + Inline edition + Upload)
run: |
cd tests/frontend
if [ -d node_modules ]; then
@@ -68,6 +66,8 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
node upload.test.mjs
node config-ai-keys.test.mjs
else
echo "tests/frontend/node_modules missing - installing jsdom"
npm install --no-audit --no-fund --silent
@@ -85,6 +85,8 @@ jobs:
node toolbar-order.test.mjs
node editor-inline.test.mjs
node ai-quick-actions.test.mjs
node upload.test.mjs
node config-ai-keys.test.mjs
fi
# ── Tests ─────────────────────────────────────────────────────────
@@ -128,7 +130,7 @@ jobs:
- name: Install dependencies
run: |
pip install bandit pip-audit
pip install bandit pip-audit semgrep
pip install -r backend/requirements.txt
- name: Bandit (SAST, bloquant — #87)
@@ -137,11 +139,21 @@ jobs:
# vrais positifs restants portent un `# nosec` justifié inline.
run: bandit -r backend/ --skip B101,B105,B110,B310
- name: Pip-audit (consultatif — #87)
# Reste non bloquant tant que les montées de version requises
# (starlette via fastapi, weasyprint) ne sont pas qualifiées :
# upgrade FastAPI = chantier de régression dédié, hors périmètre.
run: pip-audit || echo "pip-audit found vulnerabilities (non-blocking, see #87)"
- name: Semgrep (SAST local, bloquant — #87)
# Règles 100 % locales (semgrep-rules/, 8 règles) : aucun
# téléchargement de registre (runner au réseau fragile).
run: semgrep --config semgrep-rules/ backend/
- name: Pip-audit (bloquant — #87)
# Bloquant depuis T6 (#87) : dépendances qualifiées (mistune 3.3.3,
# python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1
# + starlette 1.7.0, setuptools 84 — suite complète verte + 0 vuln).
# Seule exception documentée : PYSEC-2026-1325 (ecdsa, Minerva) —
# aucun correctif upstream ET ObsiGate ne signe/vérifie qu'en HS256
# (backend/auth/jwt_handler.py), les chemins ECDSA P-256 ne
# s'exécutent jamais.
# NOTE runner Gitea Act (BUG-083) : aucun `#` dans le `run:`.
run: pip-audit --ignore-vuln PYSEC-2026-1325
# ── Docker build ──────────────────────────────────────────────────
build:
+3
View File
@@ -31,6 +31,9 @@ desktop/backend/
desktop/frontend/
backend/VERSION
# Artefacts générés par les runs E2E (excalidraw crée ces diagrammes)
test_vault/IT/e2e-diagram-*.excalidraw
# Tauri updater signing keys (private key — never commit)
desktop/*.key
desktop/*.key.pub
+121 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.28.7**.
> [Unreleased](#unreleased). La dernière version livrée est **2.28.16**.
---
@@ -14,6 +14,126 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.28.16] — 2026-09-27
---
## [2.28.15] — 2026-09-27
### Sécurité
- **#87 T6 — dépendances qualifiées, `pip-audit` bloquant (0 vulnérabilité).**
mistune 3.0.2 → 3.3.3 (XSS/ReDoS/DoS dans le moteur de rendu),
python-multipart 0.0.9 → 0.0.31, weasyprint 69 → 70, mcp 1.9.4 → 1.28.1,
fastapi 0.110.3 → 0.141.1 + starlette 0.37.2 → 1.7.0, setuptools 84 ;
`cast(str, …)` aux 3 sites d'appel mistune (typage 3.3 resserré). Suite
complète 1359 passed, ruff/mypy 0. Seule exception : PYSEC-2026-1325
(ecdsa, Minerva) — aucun correctif upstream ET JWT exclusivement HS256
(`backend/auth/jwt_handler.py`), les chemins ECDSA P-256 ne s'exécutent
jamais → `--ignore-vuln` documenté.
- **#87 T7 — semgrep SAST local bloquant (8 règles, 0 finding).**
Ruleset `semgrep-rules/` (eval/exec, shell=True, os.system, pickle,
yaml.load sans Loader, verify=False, Markup, mktemp) — 100 % local,
aucun registre réseau (runner au réseau fragile). Trivy écarté :
binaire + base de vulnérabilités à télécharger à chaque run, couche
Python déjà couverte par `pip-audit` bloquant (image = slim + 4 libs).
- **#87 T8 — fin BUG-034 : cookies Secure auto, CORS same-origin explicite.**
`OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut auto : Secure en https,
sinon rien — logins http locaux préservés ; `X-Forwarded-Proto` honoré
sous `TRUST_PROXY`, avertissement démarrage affiné, `TRUST_PROXY=true`
dans le compose prod) ; `CORSMiddleware` same-origin explicite (sûr :
web et desktop Tauri same-origin, API directe hors navigateur) ;
`style-src 'unsafe-inline'` conservé et assumé (189 attributs `style=` +
343 `el.style` — suppression = réécriture complète, risque nul côté
exécution une fois `script-src` verrouillé en T5c).
---
## [2.28.14] — 2026-09-27
---
## [2.28.13] — 2026-09-27
### Modifié
- **#87 (T5c) — `script-src` sans `'unsafe-inline'`.**
Seuls les scripts avec nonce frais (`backend/csp.py`, T5b) ou servis par
`'self'`/CDN listés s'exécutent ; `style-src` garde `'unsafe-inline'`
(chantier séparé). Vérifié : `test_csp_nonce.py` 5/5, 0 handler inline
restant dans les pages HTML (propriétés `onXxx = fn` en JS non concernées
par la CSP).
---
## [2.28.12] — 2026-09-27
### Corrigé
- **BUG-081 — `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée.**
Le pseudo-user `anonymous` (auth désactivée, mode E2E/CI) n'a aucune entrée
en store : `get_user(...)` → `None` puis `AttributeError` sur `user.get`.
Garde `None` → payload « MFA désactivé » (`mfa_enabled: false`,
`totp_enabled: false`, `webauthn_credentials: 0`). Test : `tests/test_mfa.py`
(`TestMfaStatusAuthDisabled`, échoue en 500 sans le correctif).
---
## [2.28.11] — 2026-09-27
---
## [2.28.10] — 2026-09-27
### Corrigé
- **BUG-083 — job CI `security` rouge : le runner tronquait le `#` du `run:` pip-audit.**
Le runner Gitea Act coupe naïvement au premier `#` (même entre
guillemets) : `echo "... see #87)"` devenait une citation non fermée
(`unexpected EOF while looking for matching '"'"`). Seul `run:` du
workflow avec un `#` ; l'echo n'a plus de `#` (réf `#87` en commentaire
YAML, jamais vu par le shell). Garde-fou : `tests/test_ci_workflow.py`
(aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé dans
l'étape JSDOM — BUG-082).
---
## [2.28.9] — 2026-09-27
### Corrigé
- **BUG-082 — CI `lint` rouge : suites frontend exigeant `jsdom`.**
`tests/frontend/upload.test.mjs` puis `config-ai-keys.test.mjs` (imports
statiques `jsdom`, introduits par `#89`) étaient exécutés dans l'étape
frontend racine où `jsdom` n'est jamais installé (`ERR_MODULE_NOT_FOUND`,
rouge depuis `7bee4a2`). Déplacés dans l'étape JSDOM (les deux branches,
après install si besoin) ; garde-fou `tests/test_ci_workflow.py` :
aucun fichier de l'étape racine ne doit importer `jsdom` statiquement.
---
## [2.28.8] — 2026-09-27
### Corrigé
- **BUG-080 — harnais E2E local anti-blocage (plus de run pendu toute la nuit).**
`run-e2e-local.ps1` : Playwright lancé via `node` direct sur la CLI locale
(jamais de prompt interactif, `Start-Process` ne sachant pas exécuter `npx` ;
paramètre `$Arguments`, `$Args` étant une variable automatique qui l'écraserait),
installation Chromium sautée si déjà présent (`E2E_INSTALL_BROWSERS=1`
pour forcer), étapes `install`/`test` bornées (`E2E_TIMEOUT_SEC`,
défaut 1800 s / 600 s, exit 124 au dépassement — au-delà du globalTimeout
pour un abandon propre avec rapport) ; `run-e2e-local.sh` : `npx --yes` +
mêmes bornes ; `playwright.config.ts` : `globalTimeout` (25 min en local,
30 min en CI, `E2E_GLOBAL_TIMEOUT_MS` pour surcharger) ; `e2e-server.ps1` :
pidfile resynchronisé sur le vrai propriétaire du port et `stop` qui tue
l'arbre complet (fini les serveurs orphelins qui squattent le port 2029).
Garde-fous : `tests/test_e2e_harness.py` (8 tests).
---
## [2.28.7] — 2026-09-26
### Ajouté
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.28.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.7).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.28.16).
---
*Projet : ObsiGate | Version : 2.28.7 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.28.16 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.28.7-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.28.16-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.7).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.28.16).
---
*Project: ObsiGate | Version: 2.28.7 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.28.16 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.28.7
2.28.16
+45 -16
View File
@@ -16,7 +16,7 @@ from backend.ratelimit import record_account_failure as rl_record_account_failur
from backend.ratelimit import record_account_success as rl_record_account_success
from backend.ratelimit import record_failure as rl_record_failure
from backend.ratelimit import record_success as rl_record_success
from backend.services.net import get_client_ip
from backend.services.net import get_client_ip, is_trusted_proxy
from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS,
@@ -57,15 +57,32 @@ logger = logging.getLogger("obsigate.auth.router")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def is_secure_cookies() -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3).
def is_secure_cookies(request: Request | None = None) -> bool:
"""True when auth cookies must carry the ``Secure`` flag (#87 T3/T8).
Opt-in via ``OBSIGATE_SECURE_COOKIES=true`` (required behind TLS).
Default stays ``false`` so logins keep working over plain HTTP on
trusted loopback deployments — browsers drop ``Secure`` cookies sent
over HTTP, which would silently break localhost logins.
``OBSIGATE_SECURE_COOKIES=true|false|auto`` (défaut : ``auto``) :
``true``/``false`` forcent le comportement ; ``auto`` met ``Secure``
si la requête arrive en https (production derrière TLS) et l'omet
sinon (dev local en http — les navigateurs jettent les cookies
``Secure`` sur http, ce qui casserait silencieusement les logins
localhost). Derrière un reverse proxy qui termine TLS, le schéma perçu
est http : avec ``OBSIGATE_TRUST_PROXY=true``, ``X-Forwarded-Proto``
est honoré (même garde que ``get_client_ip``, BUG-030).
"""
return os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
forced = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
if forced in ("1", "true", "yes", "on"):
return True
if forced in ("0", "false", "no", "off"):
return False
if request is None:
return False
if request.url.scheme == "https":
return True
if is_trusted_proxy():
proto = request.headers.get("x-forwarded-proto", "").split(",")[0].strip().lower()
if proto == "https":
return True
return False
# ── Pydantic request models ──────────────────────────────────────────
@@ -230,10 +247,11 @@ async def login(body: LoginRequest, response: Response, request: Request):
"remember_me": body.remember_me,
}
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response,
request: Request | None = None) -> dict:
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
record_login_success(username)
rl_record_account_success(username)
@@ -242,7 +260,7 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
max_age = 2592000 if remember_me else 604800 # 30d or 7d
secure = is_secure_cookies()
secure = is_secure_cookies(request)
response.set_cookie(
key="refresh_token",
value=refresh_token,
@@ -311,7 +329,7 @@ async def refresh_token_endpoint(request: Request, response: Response):
if stale:
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
secure = is_secure_cookies()
secure = is_secure_cookies(request)
remember_me = bool(payload.get("remember", False))
# BUG-027: rotate the refresh token — the old one is now single-use.
@@ -437,6 +455,7 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
async def change_password(
req: ChangePasswordRequest,
response: Response,
request: Request,
current_user=Depends(require_auth),
):
"""Change own password.
@@ -452,7 +471,7 @@ async def change_password(
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
result.update(_issue_tokens(updated, updated["username"], False, response))
result.update(_issue_tokens(updated, updated["username"], False, response, request))
return result
@@ -815,7 +834,7 @@ async def mfa_webauthn_verify(
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via WebAuthn")
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.get("/mfa/status")
@@ -823,6 +842,16 @@ async def mfa_status(current_user=Depends(require_auth)):
"""Return current user's MFA status."""
from .user_store import get_user
user = get_user(current_user["username"])
if user is None:
# BUG-081 : auth désactivée (OBSIGATE_AUTH_ENABLED=false) → le
# pseudo-user "anonymous" n'a aucune entrée en store : pas de MFA,
# et surtout pas de 500 (`AttributeError` sur `user.get`).
return {
"mfa_enabled": False,
"mfa_method": None,
"totp_enabled": False,
"webauthn_credentials": 0,
}
return {
"mfa_enabled": user.get("mfa_enabled", False),
"mfa_method": user.get("mfa_method"),
@@ -858,7 +887,7 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
# Clear IP rate limit on success
rl_record_success(client_ip)
return _issue_tokens(user, body.username, body.remember_me, response)
return _issue_tokens(user, body.username, body.remember_me, response, request)
@router.post("/mfa/recovery")
@@ -896,7 +925,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via recovery code")
return _issue_tokens(user, body.username, False, response)
return _issue_tokens(user, body.username, False, response, request)
# ── Admin endpoints ───────────────────────────────────────────────────
+4 -1
View File
@@ -23,6 +23,7 @@ import re
import unicodedata
import zipfile
from pathlib import Path
from typing import cast
import frontmatter
import mistune
@@ -246,7 +247,9 @@ def _render_body(md: str, file_dir: Path, vault_path: Path, current: Path) -> st
"""Render raw markdown to an HTML fragment (images inlined, wikilinks resolved)."""
md = _inline_images(md, file_dir, vault_path)
md = _convert_wikilinks(md, vault_path, current)
return _markdown(md)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le renderer
# HTML renvoie toujours `str` à l'exécution).
return cast(str, _markdown(md))
def _build_nav(vault_path: Path, current: Path) -> str:
+32 -4
View File
@@ -182,9 +182,13 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# A route may set a stricter per-response policy (e.g. ``sandbox`` for
# standalone SVG, #108-B3); keep it instead of overwriting it.
if "Content-Security-Policy" not in response.headers:
# #87 T5c : `script-src` sans 'unsafe-inline' — seuls les scripts
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'unsafe-inline' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
@@ -256,12 +260,19 @@ async def lifespan(app: FastAPI):
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# #87 T3 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair).
# #87 T3/T8 : avertir quand les cookies d'auth circulent sans flag Secure
# sur un bind non-loopback (transactions observables en clair). Avec
# `OBSIGATE_SECURE_COOKIES=auto` (défaut) + `OBSIGATE_TRUST_PROXY=true`,
# le flag suit `X-Forwarded-Proto` : pas d'avertissement, le https du
# reverse proxy est honoré.
from backend.auth.middleware import bind_host_from_argv, is_auth_enabled, is_loopback_host
from backend.auth.router import is_secure_cookies
from backend.services.net import is_trusted_proxy
if is_auth_enabled() and not is_secure_cookies() and not is_loopback_host(bind_host_from_argv()):
secure_mode = os.environ.get("OBSIGATE_SECURE_COOKIES", "auto").lower()
proxy_secure = secure_mode == "auto" and is_trusted_proxy()
if (is_auth_enabled() and not is_secure_cookies()
and not is_loopback_host(bind_host_from_argv()) and not proxy_secure):
logger.warning(
"Cookies d'authentification sans flag `Secure` sur un bind non-loopback : "
"activez TLS et posez OBSIGATE_SECURE_COOKIES=true en production."
@@ -384,6 +395,7 @@ async def _service_error_handler(request: Request, exc: ServiceError):
# GZip compression — reduces bandwidth by ~70% for text responses
# Custom wrapper: skip compression for SSE streams (/api/events)
from fastapi.middleware.gzip import GZipMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.types import Receive, Scope, Send
@@ -414,6 +426,22 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
# Security headers on all responses
app.add_middleware(SecurityHeadersMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
# seulement l'absence de middleware). Sûr pour tous les clients : web
# (same-origin), desktop Tauri (la webview est redirigée same-origin sur
# http://127.0.0.1:<port>, voir frontend/js/desktop.js) et API directe
# (curl/scripts, CORS non appliqué hors navigateur). Ajouté en dernier :
# le plus externe, les preflights court-circuitent avant tout le reste.
app.add_middleware(
CORSMiddleware,
allow_origins=[],
allow_credentials=False,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
+4 -1
View File
@@ -15,6 +15,7 @@ import html as html_mod
import re
import unicodedata
from pathlib import Path
from typing import cast
import mistune
@@ -196,7 +197,9 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
# Normalize line breaks to match Obsidian behavior (single \n → hard break)
converted = _normalize_line_breaks(converted)
rendered = _markdown_renderer(converted)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (les
# renderers HTML renvoient toujours `str` à l'exécution).
rendered = cast(str, _markdown_renderer(converted))
# Add heading IDs for TOC navigation
rendered = _add_heading_ids(rendered)
+6 -6
View File
@@ -1,9 +1,9 @@
fastapi==0.110.3
uvicorn==0.30.0
fastapi==0.141.1
uvicorn==0.54.0
websockets>=12.0
python-frontmatter==1.1.0
mistune==3.0.2
python-multipart==0.0.9
mistune==3.3.3
python-multipart==0.0.31
aiofiles==23.2.1
aiohttp>=3.9.0
watchdog>=4.0.0
@@ -11,7 +11,7 @@ argon2-cffi>=23.1.0
python-jose>=3.3.0
sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=60.0
weasyprint>=70.0
httpx>=0.27.0
pypdf>=4.0
pyotp>=2.10.0
@@ -19,7 +19,7 @@ segno>=1.5.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.9.4
mcp==1.28.1
sse-starlette==2.1.3
openpyxl>=3.1
python-docx>=1.1
+4 -2
View File
@@ -18,7 +18,7 @@ import csv as csv_lib
import io
import logging
import re
from typing import Any
from typing import Any, cast
# saxutils.escape uniquement (échappement de chaînes, aucun parsing XML).
from xml.sax import saxutils # nosec B406
@@ -173,7 +173,9 @@ def _render_markdown_pdf(content: str, title: str) -> bytes | None:
escape=False,
plugins=["table", "strikethrough", "footnotes", "task_lists"],
)
html = renderer(content)
# mistune 3.3 types `Markdown.__call__` as `str | list[...]` (le
# renderer HTML renvoie toujours `str` à l'exécution).
html = cast(str, renderer(content))
return generate_pdf(build_pdf_html(html, title), title)
except Exception as e:
# WeasyPrint loads GTK lazily: a missing native library can surface at
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.28.7"
version = "2.28.16"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.28.7"
version = "2.28.16"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.28.7",
"version": "2.28.16",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+6 -1
View File
@@ -53,7 +53,12 @@ services:
- OBSIGATE_AUTH_ENABLED=true
- OBSIGATE_ADMIN_USER=admin
# OBSIGATE_ADMIN_PASSWORD → .env
# OBSIGATE_SECURE_COOKIES=true # si derrière reverse proxy HTTPS
# OBSIGATE_SECURE_COOKIES : auto par défaut (Secure si https, sinon
# pas de flag) — forcer à true uniquement si le proxy termine TLS
# sans X-Forwarded-Proto (avec TRUST_PROXY, l'auto suffit).
# Reverse proxy devant l'app : IPs d'audit réelles (BUG-030) et
# X-Forwarded-Proto honoré pour les cookies Secure (auto).
- OBSIGATE_TRUST_PROXY=true
- OLLAMA_BASE_URL=http://ollama:11434/v1
- OLLAMA_MODEL=qwen2.5-coder:1.5b
env_file:
+9 -2
View File
@@ -188,13 +188,13 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
| *BUG-081* | `GET /api/auth/mfa/status` → 500 quand l'auth est désactivée (`user` None, `AttributeError` sur `user.get`) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/router.py::mfa_status`, `tests/test_mfa.py` | Auth désactivée : `curl http://127.0.0.1:2029/api/auth/mfa/status` → 500 (reproduit live 2026-09-27) | Garde `user is None` → payload MFA désactivé (`mfa_enabled: false`, `totp_enabled: false`, `webauthn_credentials: 0`) ; test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0 | `require_auth` laisse passer le pseudo-user anonymous, `get_user(username)` → None non gardé. Trouvé via les logs E2E pendant BUG-080 |
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
### TODOs techniques (améliorations / nouvelles tâches)
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
| *(exemple)* TODO-002 | Rendre l'index inversé incrémental (40k+ fichiers) | 🔴 ouvert | P1 | ⚙️ backend | IA | `backend/indexer.py`, `backend/search.py` | Recherche sur très gros vault | — | Exemple à remplacer. Cf. plan.md |
| *(À remplir)* | | | | | | | | | |
---
@@ -273,6 +273,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-080, BUG-081 | Correction + enregistrement | `scripts/run-e2e-local.ps1`, `scripts/run-e2e-local.sh`, `scripts/e2e-server.ps1`, `playwright.config.ts`, `tests/test_e2e_harness.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-080** : run E2E local pendu toute la nuit → harnais anti-blocage : `npx --yes` (plus de prompt interactif), install Chromium sautée si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124), `globalTimeout` Playwright (15 min local / 30 min CI, `E2E_GLOBAL_TIMEOUT_MS`), pidfile resynchronisé sur le vrai owner du port + `stop` qui tue l'arbre complet (orphelins 81180/81936 nettoyés, port 2029 libéré). Diagnostic : double processus systématique (parent `.venv` parqué + enfant qui sert — environnemental, aussi sur flowdeck/3.13). **BUG-081** (ouvert, non traité) : `GET /api/auth/mfa/status` → 500 auth désactivée (`user` None, `router.py:827`, reproduit live). Vérifié : `test_e2e_harness.py` 8/8, cycle start/stop live (pidfile cohérent, port libéré). | 🟢 corrigé (en attente vérif utilisateur) ; BUG-081 🔴 ouvert |
| 2026-09-27 | BUG-082 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-082** : `lint` rouge (`ERR_MODULE_NOT_FOUND: jsdom`, rouge depuis `7bee4a2`) — les fichiers de l'étape frontend racine à import statique `jsdom` (`upload.test.mjs`, puis `config-ai-keys.test.mjs` révélé par le CI après le 1er fix), alors que `jsdom` n'est installé que dans `tests/frontend/node_modules` (étape JSDOM). Les deux déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` généralisé (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM, contre-preuve OK). Vérifié : étape racine verte (11 suites) + `upload` et `config-ai-keys` verts depuis `tests/frontend/`. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-083 | Correction CI | `.gitea/workflows/ci.yml`, `tests/test_ci_workflow.py` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-083** : job `security` rouge — le runner Gitea Act tronque naïvement au premier `#` (même entre guillemets) : `echo "... see #87)"` devenait une citation non fermée (`unexpected EOF while looking for matching '"'"`, `/var/run/act/workflow/4` ligne 2). Seul `run:` du workflow avec un `#` (les `#` des noms d'étapes Bandit/Npm audit sont inoffensifs, ces étapes passent). Correctif : echo sans `#` (réf `#87` en commentaire YAML). Garde-fou `test_ci_workflow.py` (aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM — BUG-082) + contre-preuve sur l'ancien `ci.yml`. Vérifié : 56 passed. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | BUG-081 | Correction | `backend/auth/router.py`, `tests/test_mfa.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-081** : `GET /api/auth/mfa/status` répondait 500 quand l'auth est désactivée — le pseudo-user `anonymous` n'a aucune entrée en store (`get_user` → `None`, `AttributeError` sur `user.get`). Garde `user is None` → payload « MFA désactivé ». Test `TestMfaStatusAuthDisabled` (échoue en 500 sans le correctif). Vérifié : `test_mfa.py` 32 passed, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-27 | #87 T6, T7, T8 | Sécurité (fin #87) | `backend/requirements.txt`, `backend/{render,export}.py`, `backend/tools/documents.py`, `backend/auth/router.py`, `backend/main.py`, `semgrep-rules/` (nouveau), `.gitea/workflows/ci.yml`, `tests/test_i18n_parity.py` (nouveau), `tests/test_auth_api.py`, `tests/test_security_headers.py`, `docker-compose.yml`, `.env.example`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **T6** : dépendances qualifiées (mistune 3.3.3, multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 ; `cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant 0 vuln** (exception ecdsa/Minerva documentée : sans fix, HS256 only). **T7** : **semgrep bloquant** local 8 règles, 0 finding (trivy écarté : réseau). **T8** : Secure auto + `X-Forwarded-Proto` (`TRUST_PROXY`), warning affiné, CORS same-origin explicite, `style-src` résiduel assumé (189+343 sites) ; TODO exemple purgé, locales FR/EN 2213 parité testée, `npm audit` 0. | 🟢 corrigé (en attente vérif utilisateur) |
---
@@ -283,7 +288,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| # | Titre | Date résolution | Résolu par | Correctif / Commit | Notes |
|---|---|---|---|---|---|
| *(aucun pour l'instant)* | | | | | |
| *BUG-083* | Job CI `security` rouge : le runner Gitea Act tronque le script `pip-audit` au premier `#` (citation de l'echo non fermée → `unexpected EOF while looking for matching '"'`) | 2026-09-27 | Utilisateur | `run:` assaini (echo sans `#`, réf `#87` en commentaire YAML) ; `tests/test_ci_workflow.py` (2 tests : aucun `#` dans le code des `run:`, `upload.test.mjs` verrouillé en étape JSDOM) ; vérifié : 56 passed (ci_workflow + e2e_harness + version), contre-preuve OK sur l'ancien `ci.yml` | Seul `run:` du workflow contenant un `#` (`see #87` dans l'echo). Les `#` des noms d'étapes (Bandit, Npm audit) sont inoffensifs (ces étapes passent). Correctif : echo sans `#`, réf `#87` en commentaire YAML |
| *BUG-082* | CI `lint` rouge : suites frontend à import statique `jsdom` exécutées dans l'étape racine où `jsdom` n'est jamais installé | 2026-09-27 | Utilisateur | `upload.test.mjs` + `config-ai-keys.test.mjs` déplacés dans l'étape JSDOM (les deux branches) ; garde-fou `test_ci_workflow.py` (aucun fichier racine à import statique jsdom + suites verrouillées en JSDOM) ; vérifié : étape racine verte + `upload` et `config-ai-keys` verts depuis `tests/frontend/` | `jsdom` ne vit que dans `tests/frontend/node_modules` (installé par l'étape JSDOM). Correctif : déplacer les suites concernées dans l'étape JSDOM |
| *BUG-080* | [🔴 BLOQUANT] E2E locaux bloqués toute la nuit : `npm run test:e2e:ps` ne termine jamais (serveurs orphelins sur le port 2029, `npx playwright install` sans `--yes` ni garde-fou, suite ~130 tests sans timeout global) | 2026-09-27 | Utilisateur | `run-e2e-local` : `npx --yes`, skip install Chromium si présent (`E2E_INSTALL_BROWSERS=1`), timeouts `E2E_TIMEOUT_SEC` (900)/`E2E_BROWSER_INSTALL_TIMEOUT_SEC` (600, exit 124) ; `playwright.config.ts` : `globalTimeout` 15 min local / 30 min CI (`E2E_GLOBAL_TIMEOUT_MS`) ; `e2e-server.ps1` : pidfile = vrai owner du port, `stop` tue l'arbre complet. Tests : `tests/test_e2e_harness.py` (8/8), cycle start/stop live (pidfile cohérent, port libéré) | Constat 2026-09-27 : `e2e-server.ps1 start` OK (READY 12 s) mais run suivant pendu toute la nuit ; 2 python orphelins (PID 81180 parent + 81936 sur le port, pidfile périmé). Double processus systématique (parent `.venv` parqué + enfant qui sert — aussi sur flowdeck/3.13 : environnemental, sans impact après correctif). Trouvé au passage : BUG-081 (`/api/auth/mfa/status` → 500 auth désactivée) |
---
+6 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.28.7 | **Dernière mise à jour :** 2026-09-26
> **Version :** 2.28.16 | **Dernière mise à jour :** 2026-09-27
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -68,11 +68,15 @@
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — par tranches. **T1 livrée (v2.28.1) :** bandit bloquant (`nosec` justifiés B324/B404/B603/B607/B406, B105 exclu comme `pyproject`), `npm audit` bloquant (0 vulnérabilité), 5 suites frontend intégrées au CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`). pip-audit reste consultatif (montées starlette/weasyprint à qualifier).
- **T6 livrée (v2.28.15) :** dépendances qualifiées — mistune 3.3.3, python-multipart 0.0.31, weasyprint 70, mcp 1.28.1, fastapi 0.141.1 + starlette 1.7.0, setuptools 84 (`cast` mistune 3 sites) — suite 1359 passed, ruff/mypy 0, **`pip-audit` bloquant, 0 vulnérabilité** (seule exception documentée : PYSEC-2026-1325 ecdsa, sans correctif upstream, JWT HS256 uniquement).
- **T7 livrée (v2.28.15) :** **semgrep bloquant** sur ruleset 100 % local `semgrep-rules/` (8 règles, 0 finding, contrôle négatif OK) ; trivy écarté (binaire + DB réseau, couche Python couverte).
- **T8 livrée (v2.28.15, fin BUG-034) :** cookies `Secure` auto (`true|false|auto`, `X-Forwarded-Proto` sous `TRUST_PROXY`, warning affiné, `TRUST_PROXY=true` en prod) ; `CORSMiddleware` same-origin explicite ; `style-src 'unsafe-inline'` conservé assumé (189 `style=` + 343 `el.style`, T5c ayant verrouillé `script-src`).
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
- **Sous-tâches :**
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown) — **T4 livrée :** `tests/e2e/xss.spec.js` (BUG-021/022, 2/2 vert) + `scripts/e2e-server.ps1` (cycle de vie serveur E2E avec progression `start|stop|status|logs`) + validation locale projet `chromium-desktop` : **108/108 verts** (obsigate 44, split 37, viewers 24, xss/header 3), mobiles ciblés 10/10
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`) — **T2 livrée (v2.28.2) :** `tests/test_hardening_concurrency.py` (users.json concurrent + budget temps regex) ; 5 suites au CI (T1)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git) — **T3 livrée (v2.28.3)** (helper + avertissement + CORS attesté) ; **T5a livrée (v2.28.6)** (16 handlers inline → listeners, CSP inchangée) ; **T5b livrée :** nonce frais par réponse (`backend/csp.py`, `script-src`), injection dans les 6 pages HTML (dont nouvelle route `/excalidraw-editor.html`), `unsafe-inline` conservé (inerte) ; **T5c livrée (v2.28.13)** (`script-src` sans `unsafe-inline`) ; **T8 livrée (v2.28.15)** (fin BUG-034 : Secure auto + CORS explicite ; `style-src` résiduel assumé ; rotation DeepSeek BUG-006 toujours côté utilisateur)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD — **T6/T9 livrées (v2.28.15)** (`pip-audit` 0, `npm audit` 0, locales FR/EN 2213 clés parité testée `test_i18n_parity.py`, gardes `test_version.py` + `test_ci_workflow.py`)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.28.7",
"version": "2.28.16",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+9
View File
@@ -9,6 +9,15 @@ export default defineConfig({
reporter: process.env.CI ? 'github' : 'list',
timeout: 60000,
expect: { timeout: 10000 },
// BUG-080 : la suite (~120 tests, workers: 1, ~10-15 s/test sur un poste
// chargé) ne doit jamais pendre toute la nuit. Au-delà du timeout global,
// Playwright abandonne avec un échec explicite au lieu de bloquer.
// Surchargable : E2E_GLOBAL_TIMEOUT_MS.
globalTimeout: Number(
process.env.E2E_GLOBAL_TIMEOUT_MS ??
(process.env.CI ? 30 * 60 * 1000 : 25 * 60 * 1000),
),
reportSlowTests: process.env.CI ? null : { max: 5, threshold: 30000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:2029',
+10
View File
@@ -51,6 +51,11 @@ function Stop-Server {
if (Test-Path -LiteralPath $PidFile) {
$srvPid = (Get-Content -LiteralPath $PidFile -TotalCount 1).Trim()
if ($srvPid -match '^\d+$') {
# BUG-080 : le PID enregistré peut avoir ré-exécuté uvicorn dans un
# processus enfant (constaté : parent .venv + enfant uv-python sur
# le port) — tuer l'arbre complet, pas seulement la racine.
Get-CimInstance Win32_Process -Filter "ParentProcessId=$srvPid" -ErrorAction SilentlyContinue |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $killed += $_.ProcessId }
Stop-Process -Id $srvPid -Force -ErrorAction SilentlyContinue
$killed += $srvPid
}
@@ -146,6 +151,11 @@ switch ($Command) {
}
$health = Invoke-WebRequest -Uri "$BaseUrl/api/health" -TimeoutSec 5 -UseBasicParsing |
Select-Object -ExpandProperty Content | ConvertFrom-Json
# BUG-080 : le PID `Start-Process` peut ne pas être celui qui écoute
# (ré-exécution enfant constatée) — persister le vrai propriétaire du
# port pour un `stop` fiable, sans serveurs orphelins.
$owner = Get-PortOwner
if ($owner) { $owner.Pid | Set-Content -LiteralPath $PidFile }
Write-Host "[OK] READY — ObsiGate v$($health.version) sur $BaseUrl ($($health.total_files) fichiers)."
}
}
+59 -7
View File
@@ -10,7 +10,7 @@
conditions que le job CI `e2e`), lance la suite Playwright puis nettoie.
.PARAMETER PlaywrightArgs
Arguments transmis à `npx playwright test`, ex. `-g "image viewer"`,
Arguments transmis à `playwright test` (via `node`), ex. `-g "image viewer"`,
`--headed`.
.EXAMPLE
@@ -34,6 +34,40 @@ $BaseUrl = "http://127.0.0.1:$Port"
$ServerLog = "data/e2e-server.log"
$ServerErrLog = "data/e2e-server.err.log"
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
# E2E_TIMEOUT_SEC dépasse volontairement le globalTimeout Playwright (25 min en
# local) pour que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
$TestTimeoutSec = if ($env:E2E_TIMEOUT_SEC) { [int]$env:E2E_TIMEOUT_SEC } else { 1800 }
$BrowserTimeoutSec = if ($env:E2E_BROWSER_INSTALL_TIMEOUT_SEC) { [int]$env:E2E_BROWSER_INSTALL_TIMEOUT_SEC } else { 600 }
function Invoke-NativeWithTimeout([string]$Label, [int]$TimeoutSec, [string]$Exe, [string[]]$Arguments) {
# Lance un processus natif en gardant la sortie console en direct, et le
# tue après $TimeoutSec s'il n'a pas terminé (exit 124, comme `timeout`).
# NOTE : le paramètre NE DOIT PAS s'appeler `$Args` (variable automatique
# PowerShell qui l'écraserait → `node` lancé sans arguments, exit 0
# silencieux immédiat en lisant un stdin vide).
$stamp = Get-Date -Format "HH:mm:ss"
Write-Host "[$stamp] $Label (timeout ${TimeoutSec}s)..."
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -NoNewWindow -PassThru
$proc | Wait-Process -Timeout $TimeoutSec -ErrorAction SilentlyContinue
if (-not $proc.HasExited) {
Write-Host "[ERR] $Label : timeout après ${TimeoutSec}s, arrêt du processus (PID $($proc.Id))."
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
return 124
}
return $proc.ExitCode
}
function Test-ChromiumInstalled {
$base = Join-Path $env:USERPROFILE "AppData\Local\ms-playwright"
if (-not (Test-Path -LiteralPath $base)) { return $false }
$hit = Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "chromium-*" } |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "chrome-win\chrome.exe") } |
Select-Object -First 1
return ($null -ne $hit)
}
function Assert-Command([string]$Name, [string]$Hint) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "[ERR] $Name introuvable. $Hint"
@@ -41,7 +75,7 @@ function Assert-Command([string]$Name, [string]$Hint) {
}
Assert-Command "uv" "Installez-le : https://docs.astral.sh/uv/"
Assert-Command "npx" "Installez Node.js (>= 20)."
Assert-Command "node" "Installez Node.js (>= 20)."
# ----- Venv Python 3.11 (créé une seule fois) -----
$Python = ".venv-e2e/Scripts/python.exe"
@@ -101,14 +135,32 @@ try {
}
Write-Host "[OK] Serveur prêt."
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# ----- Playwright via node direct (pas npx) -----
# BUG-080 : `Start-Process` ne peut pas lancer `npx` (ni le `.ps1` ni le
# `.cmd` ne sont des applications Win32 directes) → on appelle la CLI
# locale via `node.exe`, sans prompt interactif possible. Skip de
# l'install si un chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1),
# timeouts dédiés sur chaque étape.
$PlaywrightCli = Join-Path $Root "node_modules/@playwright/test/cli.js"
if (-not (Test-Path -LiteralPath $PlaywrightCli)) {
throw "[ERR] $PlaywrightCli introuvable. Lancez d'abord : npm ci"
}
if (($env:E2E_INSTALL_BROWSERS -eq "1") -or (-not (Test-ChromiumInstalled))) {
$code = Invoke-NativeWithTimeout "playwright install chromium" $BrowserTimeoutSec "node" @($PlaywrightCli, "install", "chromium")
if ($code -ne 0) { exit $code }
} else {
Write-Host "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
}
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
Write-Host "[INFO] BASE_URL=$BaseUrl npx playwright test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
Write-Host "[INFO] BASE_URL=$BaseUrl node $PlaywrightCli test --project=chromium-desktop $($PlaywrightArgs -join ' ')"
$env:BASE_URL = $BaseUrl
& npx playwright test --project=chromium-desktop @PlaywrightArgs
$exitCode = $LASTEXITCODE
$testArgs = @($PlaywrightCli, "test", "--project=chromium-desktop")
if ($PlaywrightArgs) { $testArgs += @($PlaywrightArgs) }
$exitCode = Invoke-NativeWithTimeout "playwright test" $TestTimeoutSec "node" $testArgs
} catch {
Write-Host "[ERR] $($_.Exception.Message)"
$exitCode = 1
} finally {
Write-Host "[INFO] Arrêt du serveur (PID $($server.Id))..."
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue }
+23 -2
View File
@@ -23,6 +23,21 @@ cd "$(dirname "$0")/.."
PORT="${E2E_PORT:-2029}"
BASE_URL="http://127.0.0.1:$PORT"
SERVER_LOG="data/e2e-server.log"
# BUG-080 : garde-fous anti-blocage (un run E2E ne doit jamais pendre toute la nuit).
E2E_TIMEOUT_SEC="${E2E_TIMEOUT_SEC:-900}"
E2E_BROWSER_INSTALL_TIMEOUT_SEC="${E2E_BROWSER_INSTALL_TIMEOUT_SEC:-600}"
# Exécute "$@" avec un timeout dur (exit 124 comme `timeout`), sans timeout si
# la commande `timeout` est absente (ex. macOS sans coreutils).
run_with_timeout() {
local limit="$1"; shift
if command -v timeout &>/dev/null; then
timeout "$limit" "$@"
else
echo "[WARN] commande 'timeout' absente : $1 sans limite de ${limit}s" >&2
"$@"
fi
}
# ----- Prérequis -----
if ! command -v uv &>/dev/null; then
@@ -102,8 +117,14 @@ curl -sf "$BASE_URL/api/health" >/dev/null || {
}
# ----- Browsers Playwright (no-op s'ils sont déjà installés) -----
npx playwright install chromium
# BUG-080 : `--yes` (jamais de prompt interactif npx qui pend), skip si un
# chromium est déjà présent (sauf E2E_INSTALL_BROWSERS=1), timeout dédié.
if [[ "${E2E_INSTALL_BROWSERS:-0}" == "1" ]] || ! ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome &>/dev/null; then
run_with_timeout "$E2E_BROWSER_INSTALL_TIMEOUT_SEC" npx --yes playwright install chromium
else
echo "[INFO] Chromium Playwright déjà installé, étape sautée (E2E_INSTALL_BROWSERS=1 pour forcer)."
fi
# ----- Exécution de la suite (projet CI : chromium-desktop) -----
echo "[INFO] BASE_URL=$BASE_URL npx playwright test --project=chromium-desktop $*"
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop "$@"
BASE_URL="$BASE_URL" run_with_timeout "$E2E_TIMEOUT_SEC" npx --yes playwright test --project=chromium-desktop "$@"
+70
View File
@@ -0,0 +1,70 @@
# ObsiGate — règles Semgrep locales (#87 T7).
#
# Volontairement LOCALES (aucun `--config auto`/registre) : le runner CI a un
# accès réseau fragile, et ces règles n'ont besoin d'aucun téléchargement.
# Exécution : `semgrep --config semgrep-rules/ backend/` (job CI `lint`,
# bloquant). Chaque règle est un garde-fou : aucun code existant ne doit
# la déclencher (vérifié à l'ajout) ; toute violation future échoue le CI.
rules:
- id: obsigate-no-eval-exec
message: "Interdit : eval()/exec() sur du contenu dynamique (injection de code). Restructurer sans exécution de code."
severity: ERROR
languages: [python]
pattern-either:
- pattern: eval(...)
- pattern: exec(...)
- id: obsigate-no-shell-true
message: "Interdit : subprocess avec shell=True (injection shell). Passer argv en liste, shell=False."
severity: ERROR
languages: [python]
pattern-either:
- pattern: subprocess.run(..., shell=True, ...)
- pattern: subprocess.Popen(..., shell=True, ...)
- pattern: subprocess.call(..., shell=True, ...)
- pattern: subprocess.check_output(..., shell=True, ...)
- pattern: subprocess.check_call(..., shell=True, ...)
- id: obsigate-no-os-system
message: "Interdit : os.system() (shell implicite). Utiliser subprocess avec argv en liste."
severity: ERROR
languages: [python]
pattern: os.system(...)
- id: obsigate-no-pickle-load
message: "Interdit : pickle.load/loads sur des données non fiables (exécution arbitraire). Utiliser JSON."
severity: ERROR
languages: [python]
pattern-either:
- pattern: pickle.load(...)
- pattern: pickle.loads(...)
- id: obsigate-no-yaml-unsafe-load
message: "Interdit : yaml.load() sans Loader (exécution arbitraire). Utiliser yaml.safe_load()."
severity: ERROR
languages: [python]
patterns:
- pattern: yaml.load(...)
- pattern-not: yaml.load(..., Loader=...)
- id: obsigate-no-unverified-tls
message: "Interdit : verify=False (MITM). Ne jamais désactiver la vérification TLS."
severity: ERROR
languages: [python]
pattern-either:
- pattern: requests.$METHOD(..., verify=False, ...)
- pattern: httpx.$METHOD(..., verify=False, ...)
- pattern: httpx.Client(..., verify=False, ...)
- pattern: httpx.AsyncClient(..., verify=False, ...)
- id: obsigate-no-markupsafe-markup
message: "Interdit : markupsafe.Markup() (contourne l'échappement XSS, BUG-021/022). Le sanitizer serveur est la seule voie."
severity: ERROR
languages: [python]
pattern: Markup(...)
- id: obsigate-no-tempfile-mktemp
message: "Interdit : tempfile.mktemp() (race symlink, CWE-377). Utiliser NamedTemporaryFile/mkdtemp."
severity: ERROR
languages: [python]
pattern: tempfile.mktemp(...)
+80 -1
View File
@@ -402,4 +402,83 @@ class TestAvatar:
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert resp.json()["user"]["avatar"] == TINY_PNG
assert resp.json()["user"]["avatar"] == TINY_PNG
# ═══════════════════════════════════════════════════════════════════
# Secure cookies (#87 T8)
# ═══════════════════════════════════════════════════════════════════
class TestSecureCookies:
"""`Secure` auto par défaut : https → flag, http → pas de flag
(les navigateurs jettent les cookies Secure sur http)."""
@staticmethod
def _req(scheme="http", forwarded_proto=None):
from types import SimpleNamespace
headers = {}
if forwarded_proto is not None:
headers["x-forwarded-proto"] = forwarded_proto
return SimpleNamespace(
url=SimpleNamespace(scheme=scheme),
headers=headers,
)
def test_forced_true(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "true")
assert is_secure_cookies(self._req("http")) is True
def test_forced_false(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", "false")
assert is_secure_cookies(self._req("https")) is False
def test_auto_http(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies(self._req("http")) is False
def test_auto_https(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies(self._req("https")) is True
def test_auto_forwarded_proto_trusted(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
assert is_secure_cookies(self._req("http", "https")) is True
def test_auto_forwarded_proto_untrusted(self, monkeypatch):
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False)
assert is_secure_cookies(self._req("http", "https")) is False
def test_login_http_sets_cookie_without_secure(self, auth_client, monkeypatch):
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
resp = auth_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
set_cookie = resp.headers.get("set-cookie", "")
assert "access_token" in set_cookie
assert "secure" not in set_cookie.lower()
def test_login_https_sets_secure_cookie(self, auth_client, monkeypatch):
"""Même app servie en https → flag Secure présent."""
from backend.main import app
from fastapi.testclient import TestClient
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
https_client = TestClient(app, base_url="https://testserver",
raise_server_exceptions=False)
try:
resp = https_client.post("/api/auth/login", json={
"username": "admin", "password": "chab30",
})
assert resp.status_code == 200
assert "secure" in resp.headers.get("set-cookie", "").lower()
finally:
if hasattr(https_client, "close"):
https_client.close()
+115
View File
@@ -0,0 +1,115 @@
"""Garde-fous du workflow CI Gitea (BUG-082, BUG-083).
Sans dépendance (pas de PyYAML) : analyse ligne à ligne de
`.gitea/workflows/ci.yml`, suffisante pour les conventions de ce fichier.
"""
from __future__ import annotations
import re
from pathlib import Path
CI_YML = Path(__file__).resolve().parent.parent / ".gitea" / "workflows" / "ci.yml"
REPO_ROOT = Path(__file__).resolve().parent.parent
def _run_bodies() -> list[tuple[int, str]]:
"""Toutes les lignes shell de chaque bloc `run:` → [(n° ligne, code)]."""
lines = CI_YML.read_text(encoding="utf-8").splitlines()
bodies: list[tuple[int, str]] = []
i = 0
while i < len(lines):
m = re.match(r"^(\s*)run:(?:\s*\|\s*)?$", lines[i])
inline = re.match(r"^(\s*)run:\s+(\S.*)$", lines[i])
if m:
base = len(m.group(1))
i += 1
while i < len(lines):
cur = lines[i]
if not cur.strip():
i += 1
continue
if len(cur) - len(cur.lstrip()) <= base:
break
bodies.append((i + 1, cur.strip()))
i += 1
elif inline:
bodies.append((i + 1, inline.group(2).strip()))
i += 1
else:
i += 1
return bodies
class TestRunnerProofScripts:
def test_no_hash_inside_run_bodies(self):
"""BUG-083 : aucun `#` dans le code shell des `run:`.
Le runner Gitea Act tronque naïvement au premier `#` (même entre
guillemets) : `echo "... see #87)"` devenait une citation non
fermée → `unexpected EOF while looking for matching '"'` (job
`security` rouge). Les lignes-commentaires shell (`# ...`) restent
autorisées : leur troncature est sémantiquement neutre.
"""
offenders = [
f"L{n}: {code}"
for n, code in _run_bodies()
if not code.startswith("#") and "#" in code
]
assert not offenders, (
"BUG-083 : `#` interdit dans le code des `run:` "
f"(tronqué par le runner) :\n" + "\n".join(offenders)
)
class TestSemgrepStep:
def test_semgrep_local_rules_enforced(self):
"""#87 T7 : semgrep bloquant sur règles locales (aucun registre)."""
text = CI_YML.read_text(encoding="utf-8")
assert "semgrep --config semgrep-rules/ backend/" in text, (
"#87 T7 : étape semgrep locale attendue dans le job security"
)
rules = REPO_ROOT / "semgrep-rules" / "obsigate-python.yaml"
assert rules.exists(), "ruleset semgrep manquant"
class TestFrontendStepsHaveTheirDeps:
@staticmethod
def _root_step_files() -> list[str]:
"""Fichiers `node tests/frontend/<f>` de l'étape racine (sans jsdom)."""
text = CI_YML.read_text(encoding="utf-8")
root_part = text.split("Frontend JSDOM tests", 1)[0]
root_steps = root_part.split("Frontend unit tests", 1)[1]
return re.findall(r"node tests/frontend/(\S+\.mjs)", root_steps)
@staticmethod
def _has_static_jsdom_import(rel: str) -> bool:
path = REPO_ROOT / "tests" / "frontend" / rel
return any(
re.match(r"^\s*import\b.*\bfrom\s+['\"]jsdom['\"]", line)
or re.match(r"""\brequire\(\s*['"]jsdom['"]\s*\)""", line)
for line in path.read_text(encoding="utf-8").splitlines()
)
def test_root_step_files_need_no_jsdom(self):
"""BUG-082 : l'étape racine tourne sans `tests/frontend/node_modules`
(installé seulement par l'étape JSDOM) : aucun de ses fichiers ne
doit importer `jsdom` statiquement — sinon `ERR_MODULE_NOT_FOUND`
et `lint` rouge (cas `upload.test.mjs`, puis `config-ai-keys.test.mjs`).
"""
offenders = [f for f in self._root_step_files() if self._has_static_jsdom_import(f)]
assert not offenders, (
"BUG-082 : ces fichiers importent `jsdom` mais tournent dans "
"l'étape racine (sans node_modules) — les déplacer dans l'étape "
f"JSDOM :\n" + "\n".join(offenders)
)
def test_jsdom_dependent_tests_run_in_jsdom_step(self):
"""BUG-082 : les suites à import statique `jsdom` tournent bien dans
l'étape JSDOM (les deux branches)."""
text = CI_YML.read_text(encoding="utf-8")
jsdom_part = text.split("Frontend JSDOM tests", 1)[1]
for suite in ("node upload.test.mjs", "node config-ai-keys.test.mjs"):
assert jsdom_part.count(suite) >= 2, (
f"BUG-082 : `{suite}` attendu dans les deux branches de "
"l'étape JSDOM"
)
+117
View File
@@ -0,0 +1,117 @@
"""Garde-fous anti-blocage du harnais E2E local (BUG-080).
Contexte : un run `npm run test:e2e:ps` est resté pendu toute la nuit —
serveurs orphelins sur le port 2029, `npx` sans `--yes` (prompt interactif
qui attend indéfiniment), installation des navigateurs systématique et suite
Playwright (~130 tests, workers: 1) sans aucun timeout global.
Ces tests statiques vérifient que chaque couche du harnais possède son
garde-fou, afin qu'un run E2E échoue vite au lieu de bloquer indéfiniment.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _read(rel: str) -> str:
return (REPO_ROOT / rel).read_text(encoding="utf-8")
class TestE2ELocalPs:
SCRIPT = "scripts/run-e2e-local.ps1"
def test_playwright_via_node_no_npx(self):
"""Playwright est lancé via `node` direct, jamais via `npx`.
`Start-Process` ne peut pas exécuter `npx` (ni le `.ps1` ni le
`.cmd` ne sont des applications Win32 directes : "%1 is not a valid
Win32 application"), et `npx` sans `--yes` peut pendre sur un prompt
interactif. Seules les mentions en commentaires/logs sont tolérées.
"""
content = _read(self.SCRIPT)
bare = [
line.strip()
for line in content.splitlines()
if re.match(r"^\s*(?:&\s*)?npx\s", line)
]
assert not bare, f"invocations npx nues : {bare}"
assert "node_modules/@playwright/test/cli.js" in content, (
"CLI Playwright locale attendue (via node)"
)
# `$Args` est une variable automatique PowerShell : un paramètre de
# ce nom serait écrasé (helper lancé sans arguments → exit 0 muet).
# (commentaires `#` exclus : la mise en garde elle-même le cite).
code_lines = [
line for line in content.splitlines()
if not line.strip().startswith("#")
]
assert not re.search(r"\$Args\b", "\n".join(code_lines)), (
"BUG-080 : paramètre `$Args` interdit (shadowing par $args automatique)"
)
def test_browser_install_skippable(self):
"""Install navigateurs sautée si chromium déjà présent (sauf forçage)."""
content = _read(self.SCRIPT)
assert "Test-ChromiumInstalled" in content
assert "E2E_INSTALL_BROWSERS" in content
def test_test_step_has_timeout(self):
"""L'étape `playwright test` est bornée (E2E_TIMEOUT_SEC, défaut 1800).
Le défaut dépasse le globalTimeout Playwright (25 min en local) pour
que ce soit Playwright qui abandonne proprement (avec rapport) en premier.
"""
content = _read(self.SCRIPT)
assert "E2E_TIMEOUT_SEC" in content
assert "Wait-Process -Timeout" in content
assert re.search(r"E2E_TIMEOUT_SEC.*else\s*\{\s*1800\s*\}", content), (
"défaut E2E_TIMEOUT_SEC=1800 attendu"
)
class TestE2ELocalSh:
SCRIPT = "scripts/run-e2e-local.sh"
def test_npx_never_prompts(self):
content = _read(self.SCRIPT)
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#") or stripped.startswith("echo") or "npx" not in stripped:
continue
if "playwright" in stripped:
assert "--yes" in stripped, f"appel npx sans --yes : {stripped}"
def test_test_step_has_timeout(self):
content = _read(self.SCRIPT)
assert "E2E_TIMEOUT_SEC" in content
assert "run_with_timeout" in content
class TestE2EServerPs:
SCRIPT = "scripts/e2e-server.ps1"
def test_pidfile_refreshed_with_port_owner(self):
"""Le pidfile est resynchronisé sur le vrai PID d'écoute après READY."""
content = _read(self.SCRIPT)
assert "Get-PortOwner" in content
ready_pos = content.find("[OK] READY")
assert ready_pos != -1
assert "Set-Content -LiteralPath $PidFile" in content[ready_pos - 600:ready_pos]
def test_stop_kills_process_tree(self):
"""`stop` tue aussi les enfants du PID enregistré (pas d'orphelins)."""
content = _read(self.SCRIPT)
assert "ParentProcessId=$srvPid" in content
class TestPlaywrightConfig:
CONFIG = "playwright.config.ts"
def test_global_timeout_set(self):
"""Timeout global : la suite abandonne au lieu de pendre toute la nuit."""
content = _read(self.CONFIG)
assert "globalTimeout" in content
assert "E2E_GLOBAL_TIMEOUT_MS" in content
+34
View File
@@ -0,0 +1,34 @@
"""Parité i18n FR/EN des locales du frontend (#87 T9).
`frontend/locales/fr.json` et `en.json` doivent exposer exactement les mêmes
clés (comparaison profonde) : toute clé manquante fait afficher la clé brute
dans l'UI au lieu du libellé.
"""
from __future__ import annotations
import json
from pathlib import Path
LOCALES = Path(__file__).resolve().parent.parent / "frontend" / "locales"
def _flat(d: dict, prefix: str = "") -> set[str]:
keys = set()
for k, v in d.items():
name = f"{prefix}.{k}" if prefix else str(k)
if isinstance(v, dict):
keys |= _flat(v, name)
else:
keys.add(name)
return keys
def _load(lang: str) -> set[str]:
return _flat(json.loads((LOCALES / f"{lang}.json").read_text(encoding="utf-8")))
class TestI18nParity:
def test_fr_en_same_keys(self):
fr, en = _load("fr"), _load("en")
assert not (fr - en), f"clés sans traduction EN : {sorted(fr - en)[:10]}"
assert not (en - fr), f"clés sans traduction FR : {sorted(en - fr)[:10]}"
+61
View File
@@ -367,3 +367,64 @@ class TestMfaApiEndpoints:
data = login_resp.json()
assert "access_token" in data
assert data.get("mfa_required") is None
# ── BUG-081 : /api/auth/mfa/status avec auth désactivée ──────────────────
@pytest.fixture
def mfa_client_noauth():
"""TestClient avec auth DÉSACTIVÉE (OBSIGATE_AUTH_ENABLED=false)."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app, raise_server_exceptions=False)
yield client
if hasattr(client, 'close'):
client.close()
loop.run_until_complete(asyncio.sleep(0))
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_WATCHER_ENABLED"]:
os.environ.pop(k, None)
class TestMfaStatusAuthDisabled:
"""BUG-081 : `GET /api/auth/mfa/status` ne doit pas répondre 500 quand
l'auth est désactivée (pseudo-user `anonymous` sans entrée en store)."""
def test_mfa_status_anonymous_returns_disabled(self, mfa_client_noauth):
resp = mfa_client_noauth.get("/api/auth/mfa/status")
assert resp.status_code == 200, f"BUG-081: {resp.status_code} {resp.text[:200]}"
body = resp.json()
assert body["mfa_enabled"] is False
assert body["totp_enabled"] is False
assert body["webauthn_credentials"] == 0
+35 -13
View File
@@ -1,31 +1,34 @@
"""Tests — cookies Secure, CORS same-origin implicite, avertissement bind (ROADMAP #87 T3).
"""Tests — cookies Secure, CORS same-origin explicite, avertissement bind (ROADMAP #87 T3/T8).
- `is_secure_cookies()` suit `OBSIGATE_SECURE_COOKIES` (défaut `false` :
compatibilité logins en HTTP local — les navigateurs ignorent les cookies
`Secure` en clair).
- Aucun en-tête CORS permissif n'est émis : sans `CORSMiddleware`, les
navigateurs appliquent le same-origin par défaut (politique explicite par
l'absence — vérifiée ici pour qu'un ajout futur de CORS soit conscient).
- `is_secure_cookies()` : `OBSIGATE_SECURE_COOKIES=true|false|auto` (défaut
`auto` : Secure si la requête arrive en https, sinon pas de flag — les
navigateurs ignorent les cookies `Secure` en clair).
- CORS same-origin EXPLICITE : `CORSMiddleware(allow_origins=[])` — aucun
`Access-Control-Allow-*` n'est émis même avec un `Origin` cross-origin,
et les preflights sont rejetés (400).
"""
from __future__ import annotations
def test_secure_cookies_default_false(monkeypatch):
"""Défaut `false` (logins HTTP locaux préservés)."""
def test_secure_cookies_default_auto(monkeypatch):
"""Défaut `auto` : sans requête → pas de flag (logins HTTP locaux préservés)."""
from backend.auth.router import is_secure_cookies
monkeypatch.delenv("OBSIGATE_SECURE_COOKIES", raising=False)
assert is_secure_cookies() is False
def test_secure_cookies_opt_in(monkeypatch):
"""`OBSIGATE_SECURE_COOKIES=true` → flag actif (insensible à la casse)."""
def test_secure_cookies_forced_values(monkeypatch):
"""`true`/`1`/`yes` → flag ; `false`/`0`/`no` → pas de flag (insensible à la casse)."""
from backend.auth.router import is_secure_cookies
for value in ("true", "True", "TRUE", "1", "yes"):
for value in ("true", "True", "TRUE", "1", "yes", "on"):
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
assert is_secure_cookies() is (value.lower() == "true")
assert is_secure_cookies() is True
for value in ("false", "False", "FALSE", "0", "no", "off"):
monkeypatch.setenv("OBSIGATE_SECURE_COOKIES", value)
assert is_secure_cookies() is False
def test_no_cors_headers_on_api(client):
@@ -42,6 +45,25 @@ def test_no_cors_headers_on_public_share(client):
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_cross_origin_get_emits_no_acao(client):
"""#87 T8 : même avec un `Origin` cross-origin, aucun ACAO (refus explicite)."""
resp = client.get("/api/health", headers={"Origin": "http://evil.example"})
assert resp.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in resp.headers}
def test_cross_origin_preflight_rejected(client):
"""#87 T8 : preflight cross-origin → 400 (origine non autorisée)."""
resp = client.options(
"/api/health",
headers={
"Origin": "http://evil.example",
"Access-Control-Request-Method": "GET",
},
)
assert resp.status_code == 400
def test_security_headers_present(client):
"""En-têtes de durcissement posés par le middleware (non-régression)."""
resp = client.get("/api/health")