From 7bb274446c54af9b1a0c772ab80e56fa21f13bd0 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Thu, 18 Jun 2026 16:44:25 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20clean=20install=20(Gitea=20en=20dur,=20?= =?UTF-8?q?scan=20off)=20+=20durcissement=20s=C3=A9curit=C3=A9=20entrepris?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea - lien « Code source » hardcodé (REPO_SOURCE_URL) vers le dépôt, toujours visible - retrait complet du réglage configurable (api.ts, route+store settings, SettingsView, i18n, help, tests) Découverte des dépôts - aucune racine de scan par défaut → pas de scan au premier démarrage (clean install) - corrige la découverte des dépôts à l'INTÉRIEUR d'une racine qui est elle-même un repo (depth 0 = conteneur de scan, on descend ; depth > 0 = feuille) Sécurité « enterprise-deployable » - en-têtes HTTP durcis (CSP, X-Frame-Options, nosniff, Referrer-Policy, HSTS conditionnel, no-store API), header Server retiré - permissions DB 0o600 / dossier de données 0o700 ; error handler sanitisé ; garde Content-Type sur les mutations - chiffrement au repos AES-256-GCM des secrets (server_secret, clé privée VAPID) via SecretBox - journal d'audit (migration #7) + endpoint /audit-logs + RGPD export/effacement + UI Réglages - SECURITY.md, docs/ENTERPRISE_DEPLOYMENT.md, sections README EN/FR, SBOM CycloneDX en CI CI - pack-smoke packe depuis le contexte du package (cd packages/server) au lieu de « npm pack -w » : corrige l'embarquement de @arboretum/shared dans le tarball Purge des données personnelles du dépôt public - suppression de spikes/s4-discovery/result.json, reformulation du VERDICT - chemin de test générique, anonymisation des 5 fixtures de dialogues Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 19 +- .github/workflows/release.yml | 7 + README.fr.md | 4 + README.md | 4 + SECURITY.md | 61 +++++++ docs/ENTERPRISE_DEPLOYMENT.md | 95 ++++++++++ packages/server/src/app.ts | 84 ++++++++- packages/server/src/auth/service.ts | 20 ++- packages/server/src/config.ts | 10 +- packages/server/src/core/audit-log.ts | 65 +++++++ packages/server/src/core/push-service.ts | 14 +- packages/server/src/core/repo-scanner.ts | 11 +- packages/server/src/core/scan-settings.ts | 11 +- packages/server/src/core/secret-box.ts | 69 ++++++++ packages/server/src/db/index.ts | 36 ++++ packages/server/src/routes/audit.ts | 17 ++ packages/server/src/routes/auth.ts | 11 +- packages/server/src/routes/data.ts | 54 ++++++ packages/server/src/routes/groups.ts | 7 +- packages/server/src/routes/push.ts | 6 +- packages/server/src/routes/settings.ts | 43 +---- .../server/test/audit-data-routes.test.ts | 164 ++++++++++++++++++ .../server/test/fixtures/dialogs/ask2.raw | 14 +- .../test/fixtures/dialogs/deny-esc2.raw | 4 +- .../test/fixtures/dialogs/perm-bash2.raw | 2 +- .../test/fixtures/dialogs/perm-write2.raw | 18 +- .../server/test/fixtures/dialogs/trust.raw | 2 +- packages/server/test/jsonl-discovery.test.ts | 2 +- packages/server/test/repo-scanner.test.ts | 10 ++ packages/server/test/secret-box.test.ts | 37 ++++ packages/server/test/settings-routes.test.ts | 36 +--- packages/shared/src/api.ts | 45 ++++- packages/web/src/composables/useNav.ts | 20 +-- packages/web/src/i18n/en.ts | 16 +- packages/web/src/i18n/fr.ts | 16 +- packages/web/src/lib/constants.ts | 8 + packages/web/src/stores/settings.ts | 6 +- packages/web/src/views/SettingsView.vue | 112 ++++++++---- packages/web/src/views/help-content.ts | 8 +- spikes/s4-discovery/VERDICT.md | 2 +- spikes/s4-discovery/result.json | 141 --------------- 41 files changed, 973 insertions(+), 338 deletions(-) create mode 100644 SECURITY.md create mode 100644 docs/ENTERPRISE_DEPLOYMENT.md create mode 100644 packages/server/src/core/audit-log.ts create mode 100644 packages/server/src/core/secret-box.ts create mode 100644 packages/server/src/routes/audit.ts create mode 100644 packages/server/src/routes/data.ts create mode 100644 packages/server/test/audit-data-routes.test.ts create mode 100644 packages/server/test/secret-box.test.ts create mode 100644 packages/web/src/lib/constants.ts delete mode 100644 spikes/s4-discovery/result.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7fae8..e74c1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,17 +47,14 @@ jobs: - name: Pack tarball run: | rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs - # Le hook prepack (copy-web + vendor-shared + copy-meta) matérialise - # @arboretum/shared comme VRAI dossier dans le node_modules du serveur — - # condition pour que npm l'embarque via bundleDependencies. Selon la version - # de npm du runner, « npm pack -w » ne déclenche PAS toujours ce lifecycle - # (contrairement à « npm publish -w », qui lance bien prepack). On l'exécute - # donc explicitement, on vérifie la matérialisation, puis on packe avec - # --ignore-scripts (prepack déjà fait, dossier physique présent → bundlé). - npm run prepack -w @johanleroy/git-arboretum - test -f packages/server/node_modules/@arboretum/shared/dist/index.js \ - || { echo "ERREUR: prepack n'a pas matérialisé @arboretum/shared/dist/index.js"; exit 1; } - npm pack -w @johanleroy/git-arboretum --pack-destination /tmp/tarballs --ignore-scripts + # @arboretum/shared (dépendance runtime non publiée) est embarquée via bundleDependencies, + # matérialisée par le hook prepack (copy-web + vendor-shared + copy-meta) comme VRAI dossier + # dans packages/server/node_modules/@arboretum/shared. + # On packe depuis le CONTEXTE DU PACKAGE (cd), PAS en mode workspace (-w) : « npm pack -w » + # résout @arboretum/shared via le symlink workspace hoisté de la racine et n'embarque donc + # PAS le dossier vendored local. « cd packages/server && npm pack » lance le lifecycle pack + # complet et reproduit fidèlement « npm publish » (qui, lui, embarque correctement). + ( cd packages/server && npm pack --pack-destination /tmp/tarballs ) ls -l /tmp/tarballs - name: Assert @arboretum/shared is bundled in the tarball run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bfd8f1..86f46f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,3 +40,10 @@ jobs: # Mapper le secret du registre (PAT Gitea write:package, ou le token auto # GITEA_TOKEN s'il a ce scope) sur NODE_AUTH_TOKEN lu par le .npmrc de setup-node. NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # SBOM (transparence supply-chain entreprise) : généré pour le paquet publié et exposé en artefact. + - name: Generate SBOM (CycloneDX) + run: npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json -w @johanleroy/git-arboretum || npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json + - uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.json diff --git a/README.fr.md b/README.fr.md index 7e8cf40..a6b7d3a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -209,9 +209,13 @@ Un terminal web, c'est de l'exécution de code à distance *par conception*. Les - Se bind sur `127.0.0.1` par défaut ; refuse les binds non-loopback sans flag explicite. - Authentifie **chaque** requête `/api/**` **et** chaque upgrade `/ws` avec des tokens révocables, et applique un **check `Origin` strict** (le cookie `SameSite=Strict` ne couvre pas les upgrades WebSocket — c'est le garde-fou anti cross-site hijacking). - Les tokens sont stockés **hashés** (sha256) et comparés en temps constant ; le bootstrap token n'est affiché qu'une seule fois. Le cookie de session est un payload signé HMAC, `HttpOnly` et `SameSite=Strict`, et reçoit automatiquement le flag `Secure` quand la requête arrive en HTTPS (p. ex. derrière Tailscale Serve). Le login est rate-limité avec backoff exponentiel. +- Envoie des en-têtes HTTP durcis (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, HSTS conditionnel, `no-store` sur l'API), restreint le dossier de données à `0o700` et la base à `0o600`, et **chiffre les secrets sensibles au repos** (AES-256-GCM). +- Tient un **journal d'audit** des opérations sensibles et offre l'**export/effacement RGPD** des données (Réglages → Sécurité & conformité). Tailscale Serve est **la** façon d'atteindre Arboretum depuis d'autres appareils — pas seulement une recommandation : HTTPS valide, identité tailnet, aucun port ouvert. Le flag `--i-know-this-exposes-a-terminal` est une trappe de secours, pas un mode de déploiement ; n'exposez jamais Arboretum directement sur internet. +Voir [`SECURITY.md`](SECURITY.md) pour le modèle de menace complet et [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) pour le durcissement en environnement réglementé. + ## Ce qui le distingue | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | diff --git a/README.md b/README.md index 8ccebe7..5273761 100644 --- a/README.md +++ b/README.md @@ -209,9 +209,13 @@ A web terminal is remote code execution *by design*. Arboretum's guardrails are - Binds to `127.0.0.1` by default; refuses non-loopback binds without an explicit flag. - Authenticates **every** `/api/**` request **and** every `/ws` upgrade with revocable tokens, and applies a **strict `Origin` check** (the `SameSite=Strict` cookie does not cover WebSocket upgrades — this is the anti cross-site hijacking guard). - Tokens are stored **hashed** (sha256) and compared in constant time; the bootstrap token is shown only once. The session cookie is an HMAC-signed payload, `HttpOnly` and `SameSite=Strict`, and it automatically gains the `Secure` flag when the request arrives over HTTPS (e.g. behind Tailscale Serve). Login is rate-limited with exponential backoff. +- Sends hardened HTTP headers (CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, conditional HSTS, `no-store` on the API), restricts the data directory to `0o700` and the database to `0o600`, and **encrypts sensitive secrets at rest** (AES-256-GCM). +- Keeps an **audit log** of sensitive operations and offers **GDPR** data export/erasure (Settings → Security & compliance). Tailscale Serve is **the** way to reach Arboretum from other devices — not just a recommendation: valid HTTPS, tailnet identity, no open ports. The `--i-know-this-exposes-a-terminal` flag is an escape hatch, not a deployment mode; never expose Arboretum directly to the internet. +See [`SECURITY.md`](SECURITY.md) for the full threat model and [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md) for hardening in regulated environments. + ## What makes it different | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f6fd628 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,61 @@ +# Security Policy + +Arboretum is a self-hosted daemon that serves a web dashboard to drive git worktrees and the +Claude Code sessions running on them. **A web terminal is remote code execution by design** — that +is the product, not a bug. Arboretum's security model is therefore built on *structural* guards +(loopback-only binding, authenticated access, strict Origin checks) far more than on cryptography +alone. + +This document describes the threat model, the controls that are implemented, the deliberate +trade-offs, and how to report a vulnerability. For hardening a deployment in a regulated +environment, see [`docs/ENTERPRISE_DEPLOYMENT.md`](docs/ENTERPRISE_DEPLOYMENT.md). + +## Threat model + +- **Single-user by design.** Arboretum runs on the owner's machine and is meant for one operator. + There is no multi-tenant isolation and no RBAC — and none is claimed. +- **Loopback by default.** The server binds `127.0.0.1`; `config.ts` *refuses* any non-loopback bind + unless you pass `--i-know-this-exposes-a-terminal`. Remote access is expected via **Tailscale Serve** + (TLS + tailnet identity), never by opening a port. +- **The terminal is RCE.** Anyone who can authenticate can run code. The controls below exist to make + sure only the authenticated operator reaches it, and that the surrounding surface (cookies, headers, + data at rest) is hardened. + +## Implemented controls + +| Area | Control | Where | +| --- | --- | --- | +| Network | Loopback-only default; non-loopback refused without explicit flag | `packages/server/src/config.ts` | +| AuthN | Global guard on **all** `/api/**` and `/ws` (only login is public, and rate-limited) | `packages/server/src/app.ts` | +| AuthN | Tokens stored **hashed** (SHA-256), compared in constant time; bootstrap token shown once | `packages/server/src/auth/service.ts` | +| Sessions | Cookie is an HMAC-SHA256 signed payload, `HttpOnly` + `SameSite=Strict`, `Secure` when HTTPS | `packages/server/src/routes/auth.ts` | +| CSRF / WS | Strict `Origin` check on every `/api/**` and `/ws` request (anti cross-site WS hijacking) | `packages/server/src/app.ts` | +| CSRF | Mutations carrying a body must be `application/json` | `packages/server/src/app.ts` | +| Rate limit | Global login rate limit with exponential backoff (not per-IP — Tailscale fronts everything as 127.0.0.1) | `packages/server/src/auth/service.ts` | +| HTTP headers | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `Permissions-Policy`, conditional HSTS, `Cache-Control: no-store` on API; `Server` header stripped | `packages/server/src/app.ts` | +| Injection | All SQL is parameterized; all git calls use `execFile` (no shell); path-traversal guards | `packages/server/src/**` | +| Data at rest | DB file/dir forced to `0o600`/`0o700`; sensitive secrets (server secret, VAPID private key) encrypted with AES-256-GCM | `packages/server/src/db/index.ts`, `core/secret-box.ts` | +| Audit | Persistent audit log of sensitive mutations (tokens, settings, secrets, push, groups) | `packages/server/src/core/audit-log.ts` | +| Privacy | GDPR export (`/api/v1/data/export`) and erasure (`/api/v1/data/delete-my-data`) | `packages/server/src/routes/data.ts` | +| Install | Runs as a **user** service (systemd user unit / launchd LaunchAgent), never root | `packages/server/src/cli/install.ts` | + +## Deliberate trade-offs + +- **Long-lived API tokens.** Tokens do not expire by age (CLI automation stability) but can be revoked + instantly, and `last_used_at` is tracked. Review and rotate tokens periodically. +- **Encryption-at-rest key management.** With no `ARBORETUM_SECRET_KEY` set, the encryption key lives in + `dataDir/secret.key` (`0o600`) next to the database — this protects a leaked database *copy* (backup, + WAL) but not a full `dataDir` compromise. For strong protection, set `ARBORETUM_SECRET_KEY` and store it + separately from database backups. Full-DB SQLCipher is intentionally avoided (it breaks the `npx` + prebuilt portability). +- **No multi-user model.** If you need multiple operators with isolation, Arboretum is not the right tool. + +## Reporting a vulnerability + +Please report security issues **privately** — do not open a public issue. + +- Email: **security@johanleroy.fr** (or `contact@johanleroy.fr`). +- Include a description, affected version, and reproduction steps. +- Expect an acknowledgement within **7 days** and a coordinated disclosure window of up to **90 days**. + +Thank you for helping keep Arboretum users safe. diff --git a/docs/ENTERPRISE_DEPLOYMENT.md b/docs/ENTERPRISE_DEPLOYMENT.md new file mode 100644 index 0000000..227df7a --- /dev/null +++ b/docs/ENTERPRISE_DEPLOYMENT.md @@ -0,0 +1,95 @@ +# Enterprise deployment guide + +This guide complements [`../SECURITY.md`](../SECURITY.md) with the operational steps a regulated or +security-conscious organization needs to deploy Arboretum with confidence. + +## 1. Remote access: Tailscale Serve (recommended) + +Never open a public port. Keep the default `127.0.0.1` bind and put Arboretum behind Tailscale Serve: + +```bash +# on the host running arboretum (default bind 127.0.0.1:7317) +tailscale serve --bg 7317 +``` + +This gives you TLS, a stable `*.ts.net` hostname, and tailnet identity. Then add that origin to the +allow-list so the strict Origin check accepts it: + +```bash +arboretum --allow-origin https://my-host.tailnet.ts.net +``` + +The `--i-know-this-exposes-a-terminal` flag exists only as an escape hatch for non-loopback binds; it +is **never** a deployment mode and is never injected automatically by `arboretum install`. + +## 2. Encryption at rest + +Sensitive secrets (the HMAC server secret and the VAPID private key) are encrypted with AES-256-GCM +before being stored in SQLite. Token values are never stored — only their SHA-256 hashes. + +For **strong** protection (key not co-located with the database), provide a passphrase via the +environment instead of the on-disk key file: + +```bash +ARBORETUM_SECRET_KEY='' arboretum +``` + +- Store this passphrase in your secrets manager / KMS, **separately** from database backups. +- Without it, the key is auto-generated in `dataDir/secret.key` (`0o600`). This still protects a leaked + database copy, but not a full `dataDir` compromise. +- Rotating the passphrase requires re-encrypting existing values; the simplest path is to revoke and + recreate the bootstrap token after rotation. + +## 3. Filesystem permissions + +On startup Arboretum forces `dataDir` to `0o700` and the database files (`*.db`, `-wal`, `-shm`) to +`0o600`. Verify after first run: + +```bash +stat -c '%a %n' ~/.local/share/arboretum ~/.local/share/arboretum/*.db +# expect: 700 …/arboretum and 600 …/arboretum.db +``` + +Keep `$HOME` private (standard `0o700`). If you relocate data with `--db` or `XDG_DATA_HOME`, make sure +the target directory is not world-readable. + +## 4. Audit logging + +All sensitive mutations are recorded in an append-only `audit_logs` table: token create/revoke, login +success/failure, settings changes, secret generation, push subscribe/unsubscribe, group CRUD, and data +erasure. Query it via the API (paginated): + +```bash +curl -s -H "Authorization: Bearer $TOKEN" \ + 'http://127.0.0.1:7317/api/v1/audit-logs?limit=100' +``` + +The audit log never contains secret values — only non-sensitive metadata (ids, labels, counters). It is +also visible in the dashboard under **Settings → Security & compliance**. + +## 5. GDPR (data subject requests) + +- **Export**: `GET /api/v1/data/export` returns every record tied to the authenticated token (token + metadata, push subscriptions, session history, settings) as JSON. Also available as a one-click + download in **Settings → Security & compliance**. +- **Erasure**: `POST /api/v1/data/delete-my-data` is a two-step call — the first response returns a + `confirm` code that must be POSTed back to execute. It purges the token's push subscriptions and + revokes the token (unless it is the last active one). + +## 6. Backups & retention + +- Back up the SQLite database (`arboretum.db`) with the WAL checkpointed. Treat backups as sensitive. +- If you use `ARBORETUM_SECRET_KEY`, back the key up **separately** — a database backup is useless (and + safe) without it, which is the point. +- Session history is retained until the database is reset. To start clean, stop the service and remove + the database file. + +## 7. Logging hygiene + +The log level is controlled by `ARBORETUM_LOG` (default `info`). Do **not** run `debug`/`trace` in +production: verbose levels may log request metadata. Keep `info` or higher. + +## 8. Supply chain + +Each published release ships with a CycloneDX SBOM (`sbom.json`) generated in CI, so you can scan the +dependency tree for known vulnerabilities before deploying. diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 26414d7..26cf181 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -1,4 +1,4 @@ -import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify'; +import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; @@ -14,6 +14,7 @@ import { WorktreeManager } from './core/worktree-manager.js'; import { RepoDiscoveryService } from './core/repo-discovery.js'; import { GroupManager } from './core/group-manager.js'; import { PushService } from './core/push-service.js'; +import { loadSecretBox } from './core/secret-box.js'; import { registerAuthRoutes } from './routes/auth.js'; import { registerSessionRoutes } from './routes/sessions.js'; import { registerRepoRoutes } from './routes/repos.js'; @@ -22,7 +23,37 @@ import { registerWorktreeRoutes } from './routes/worktrees.js'; import { registerPushRoutes } from './routes/push.js'; import { registerSettingsRoutes } from './routes/settings.js'; import { registerFsRoutes } from './routes/fs.js'; +import { registerAuditRoutes } from './routes/audit.js'; +import { registerDataRoutes } from './routes/data.js'; import { registerWsGateway } from './ws/gateway.js'; +import { isHttpsRequest } from './routes/auth.js'; + +// En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners +// entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et +// le transport, ils ne remplacent pas le modèle loopback + auth + Origin. +const SECURITY_HEADERS: Record = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=(), payment=()', + // CSP calibrée pour la SPA : Tailwind/xterm injectent du style inline ; le WebSocket impose + // ws:/wss: en connect-src ; le service worker push impose worker-src 'self'. + 'Content-Security-Policy': [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self' ws: wss:", + "worker-src 'self'", + "manifest-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "object-src 'none'", + "form-action 'self'", + ].join('; '), +}; declare module 'fastify' { interface FastifyRequest { @@ -46,9 +77,11 @@ export interface AppBundle { export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle { const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } }); - const auth = new AuthService(db); + // Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base. + const box = loadSecretBox(config.dataDir); + const auth = new AuthService(db, box); const limiter = new LoginRateLimiter(); - const push = new PushService(db, config.vapidContact); + const push = new PushService(db, config.vapidContact, undefined, box); const manager = new PtyManager(db, config.claudeSessionsDir, push); const discovery = new DiscoveryService({ ptyManager: manager, @@ -60,6 +93,43 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund const repoDiscovery = new RepoDiscoveryService(db, worktrees); const groups = new GroupManager(db); + // En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS). + // onSend DOIT retourner le payload (sinon Fastify vide la réponse). + app.addHook('onSend', async (req, reply, payload) => { + reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent) + for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v); + if (isHttpsRequest(req)) { + reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) { + reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'); + } + return payload; + }); + + // Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json + // (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon). + app.addHook('preHandler', async (req, reply) => { + if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return; + const len = req.headers['content-length']; + if (!len || len === '0') return; // pas de corps : rien à valider + const ct = (req.headers['content-type'] ?? '').toLowerCase(); + if (!ct.startsWith('application/json')) { + return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } }); + } + }); + + // Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs. + app.setErrorHandler((err: FastifyError, req, reply) => { + const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500; + if (status >= 500) { + req.log.error({ err }, 'unhandled error'); + return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } }); + } + // erreurs client (4xx) : code générique, message court non sensible. + return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } }); + }); + void app.register(fastifyCookie); void app.register(fastifyWebsocket, { options: { maxPayload: 1024 * 1024 }, @@ -97,14 +167,16 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund } }); - registerAuthRoutes(app, auth, limiter, serverVersion); + registerAuthRoutes(app, auth, limiter, serverVersion, db); registerSessionRoutes(app, manager, discovery); registerRepoRoutes(app, worktrees, db); - registerGroupRoutes(app, groups); + registerGroupRoutes(app, groups, db); registerWorktreeRoutes(app, worktrees); - registerPushRoutes(app, push); + registerPushRoutes(app, push, db); registerSettingsRoutes(app, db, config, serverVersion, push); registerFsRoutes(app); + registerAuditRoutes(app, db); + registerDataRoutes(app, db, auth); // La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // encapsulé) — sinon le handler reçoit la signature REST (request, reply). void app.register(async (scoped) => { diff --git a/packages/server/src/auth/service.ts b/packages/server/src/auth/service.ts index bb31633..304f21f 100644 --- a/packages/server/src/auth/service.ts +++ b/packages/server/src/auth/service.ts @@ -1,5 +1,7 @@ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { type Db, getSetting, setSetting } from '../db/index.js'; +import type { SecretBox } from '../core/secret-box.js'; +import { recordAudit } from '../core/audit-log.js'; const COOKIE_NAME = 'arb_session'; const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000; @@ -12,11 +14,21 @@ export interface AuthContext { export class AuthService { private readonly secret: Buffer; - constructor(private readonly db: Db) { - let secretHex = getSetting(db, 'server_secret'); - if (!secretHex) { + constructor( + private readonly db: Db, + box?: SecretBox, + ) { + // server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce : + // une valeur en clair pré-existante est re-chiffrée à la lecture. + const stored = getSetting(db, 'server_secret'); + let secretHex: string; + if (!stored) { secretHex = randomBytes(32).toString('hex'); - setSetting(db, 'server_secret', secretHex); + setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex); + recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' }); + } else { + secretHex = box ? box.decrypt(stored) : stored; + if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex)); } this.secret = Buffer.from(secretHex, 'hex'); } diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index f95c02f..cc52f2a 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -1,7 +1,7 @@ import { parseArgs } from 'node:util'; import { join } from 'node:path'; import { homedir } from 'node:os'; -import { mkdirSync } from 'node:fs'; +import { chmodSync, mkdirSync } from 'node:fs'; export interface Config { port: number; @@ -53,6 +53,14 @@ export function loadConfig(argv = process.argv.slice(2)): Config { const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum'); mkdirSync(dataDir, { recursive: true }); + // La DB contient des secrets (server_secret, clé privée VAPID, hashs de tokens) : le dossier de + // données ne doit jamais être lisible par d'autres utilisateurs du système. chmod best-effort + // (peut échouer sur certains FS Windows/montés ; le démarrage avertit alors sans bloquer). + try { + chmodSync(dataDir, 0o700); + } catch { + /* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */ + } const claudeHome = values['claude-home'] ?? join(homedir(), '.claude'); return { port: Number(values.port), diff --git a/packages/server/src/core/audit-log.ts b/packages/server/src/core/audit-log.ts new file mode 100644 index 0000000..f6323af --- /dev/null +++ b/packages/server/src/core/audit-log.ts @@ -0,0 +1,65 @@ +// Journal d'audit : trace persistante des opérations sensibles (création/révocation de tokens, +// changements de réglages, génération de secrets, abonnements push, CRUD groupes). Exigence de +// conformité entreprise (GDPR/SOX/ISO 27001). Règle ABSOLUE : ne JAMAIS journaliser un secret en +// clair — `details` ne contient que des métadonnées non sensibles (ids, labels, compteurs). +import { randomUUID } from 'node:crypto'; +import type { AuditLogEntry } from '@arboretum/shared'; +import type { Db } from '../db/index.js'; + +export type AuditResult = 'ok' | 'denied' | 'error'; + +export interface AuditEntry { + /** tokenId de l'acteur, ou 'system' (opérations automatiques), ou 'anonymous' (avant auth). */ + actor: string; + /** verbe.objet, ex. 'token.create', 'settings.update', 'login.failure'. */ + action: string; + resourceId?: string | null; + details?: Record | null; + result?: AuditResult; +} + +/** Enregistre une entrée d'audit. Best-effort : une erreur d'écriture ne casse jamais l'opération métier. */ +export function recordAudit(db: Db, e: AuditEntry): void { + try { + db.prepare( + 'INSERT INTO audit_logs (id, ts, actor, action, resource_id, details, result) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run( + randomUUID(), + new Date().toISOString(), + e.actor, + e.action, + e.resourceId ?? null, + e.details ? JSON.stringify(e.details) : null, + e.result ?? 'ok', + ); + } catch { + /* l'audit ne doit jamais faire échouer l'action auditée */ + } +} + +/** Liste paginée par date décroissante (curseur `before` = ts strictement inférieur). */ +export function listAudit(db: Db, opts: { limit: number; before?: string | null }): AuditLogEntry[] { + const limit = Math.min(Math.max(Math.trunc(opts.limit) || 50, 1), 200); + const rows = ( + opts.before + ? db + .prepare( + 'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs WHERE ts < ? ORDER BY ts DESC LIMIT ?', + ) + .all(opts.before, limit) + : db + .prepare( + 'SELECT id, ts, actor, action, resource_id AS resourceId, details, result FROM audit_logs ORDER BY ts DESC LIMIT ?', + ) + .all(limit) + ) as Array<{ id: string; ts: string; actor: string; action: string; resourceId: string | null; details: string | null; result: string }>; + return rows.map((r) => ({ ...r, details: r.details ? safeParse(r.details) : null })); +} + +function safeParse(s: string): unknown { + try { + return JSON.parse(s); + } catch { + return s; + } +} diff --git a/packages/server/src/core/push-service.ts b/packages/server/src/core/push-service.ts index cde58d4..147ec8a 100644 --- a/packages/server/src/core/push-service.ts +++ b/packages/server/src/core/push-service.ts @@ -4,6 +4,8 @@ import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; import { type Db, getSetting, setSetting } from '../db/index.js'; +import type { SecretBox } from './secret-box.js'; +import { recordAudit } from './audit-log.js'; // web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext), // typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts. @@ -47,16 +49,22 @@ export class PushService { private readonly db: Db, private readonly contact: string = 'mailto:arboretum@localhost', sender?: PushSender, + box?: SecretBox, ) { this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters[2])); - let pub = getSetting(db, 'vapid_public'); - let priv = getSetting(db, 'vapid_private'); + let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer) + const storedPriv = getSetting(db, 'vapid_private'); + let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null; if (!pub || !priv) { const keys = webpush.generateVAPIDKeys(); pub = keys.publicKey; priv = keys.privateKey; setSetting(db, 'vapid_public', pub); - setSetting(db, 'vapid_private', priv); + setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv); + recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' }); + } else if (box && storedPriv && !box.isEncrypted(storedPriv)) { + // migration douce : clé privée pré-existante en clair → re-chiffrée. + setSetting(db, 'vapid_private', box.encrypt(priv)); } this.vapidPublic = pub; this.vapidPrivate = priv; diff --git a/packages/server/src/core/repo-scanner.ts b/packages/server/src/core/repo-scanner.ts index 604a595..2a0fa80 100644 --- a/packages/server/src/core/repo-scanner.ts +++ b/packages/server/src/core/repo-scanner.ts @@ -37,7 +37,9 @@ interface Frame { * Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`. * Règles : * - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo : - * on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ; + * on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués + * ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un + * conteneur — on l'enregistre ET on continue de descendre pour trouver les repos internes ; * - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS * suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ; * - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ; @@ -68,10 +70,13 @@ export async function scanForRepos( if (seen.has(dir)) continue; seen.add(dir); - // un repo : on l'enregistre et on ne descend pas. + // Un dossier avec `.git` est un repo. En PROFONDEUR (depth > 0) c'est une feuille : on + // l'enregistre sans descendre (on n'ouvre pas les sous-modules/worktrees imbriqués). Mais une + // RACINE fournie explicitement (depth 0) est un CONTENEUR de scan : si elle est elle-même un + // repo on l'enregistre, puis on CONTINUE de descendre pour découvrir les dépôts qu'elle contient. if (existsSync(join(dir, '.git'))) { found.add(dir); - continue; + if (depth > 0) continue; } if (depth >= limits.maxDepth) continue; diff --git a/packages/server/src/core/scan-settings.ts b/packages/server/src/core/scan-settings.ts index 686564c..ef962bb 100644 --- a/packages/server/src/core/scan-settings.ts +++ b/packages/server/src/core/scan-settings.ts @@ -1,7 +1,6 @@ // Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur). // Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH // /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici. -import { homedir } from 'node:os'; import { getSetting } from '../db/index.js'; import type { Db } from '../db/index.js'; import { isSafeAbsolutePath } from './git.js'; @@ -15,12 +14,14 @@ export const DEFAULT_SCAN_INTERVAL_MIN = 5; export const MAX_SCAN_INTERVAL_MIN = 1440; export const MAX_SCAN_ROOTS = 16; -/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */ +/** + * Racines à scanner. Défaut : AUCUNE racine → aucun scan (clean install). + * L'utilisateur ajoute ses racines via Réglages → Découverte. Lecture tolérante (JSON malformé → []). + */ export function readScanRoots(db: Db): string[] { const raw = getSetting(db, SCAN_ROOTS_KEY); - if (!raw) return [homedir()]; - const parsed = normalizeScanRoots(safeParse(raw)); - return parsed && parsed.length > 0 ? parsed : [homedir()]; + if (!raw) return []; + return normalizeScanRoots(safeParse(raw)) ?? []; } /** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */ diff --git a/packages/server/src/core/secret-box.ts b/packages/server/src/core/secret-box.ts new file mode 100644 index 0000000..736d761 --- /dev/null +++ b/packages/server/src/core/secret-box.ts @@ -0,0 +1,69 @@ +// Chiffrement au repos des secrets applicatifs (server_secret HMAC, clé privée VAPID) stockés dans +// la table `settings`. node:sqlite (DatabaseSync) ne supporte pas sqlcipher → on chiffre au niveau +// applicatif les VALEURS sensibles, en AES-256-GCM (authentifié), avant insertion. +// +// Gestion de clé (par ordre de priorité) : +// 1. ARBORETUM_SECRET_KEY (variable d'env) → vraie protection : la clé ne vit pas sur le disque, +// donc une fuite de la base seule (backup, WAL) ne révèle pas les secrets ; +// 2. sinon, fichier clé `dataDir/secret.key` (0o600), généré au 1er démarrage. Protection partielle : +// couvre la fuite de la base seule, PAS celle du dossier de données complet (clé + base ensemble). +// Compromis assumé et documenté (docs/ENTERPRISE_DEPLOYMENT.md) : pour une protection forte, fournir +// ARBORETUM_SECRET_KEY et sauvegarder cette clé séparément des backups de la base. +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'; +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const PREFIX = 'v1:'; // versionne le format ; absence de préfixe = valeur en clair (legacy → migration douce) +const SCRYPT_SALT = 'arboretum-secret-box-v1'; // sel fixe : l'entropie vient de la passphrase fournie + +export class SecretBox { + constructor(private readonly key: Buffer) {} + + /** Chiffre en `v1:::` (hex). */ + encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`; + } + + /** Déchiffre une valeur `v1:` ; une valeur sans préfixe est retournée telle quelle (clair legacy). */ + decrypt(stored: string): string { + if (!this.isEncrypted(stored)) return stored; + const [, ivHex, tagHex, ctHex] = stored.split(':'); + if (!ivHex || !tagHex || !ctHex) throw new Error('secret-box: format chiffré invalide'); + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(ivHex, 'hex')); + decipher.setAuthTag(Buffer.from(tagHex, 'hex')); + return Buffer.concat([decipher.update(Buffer.from(ctHex, 'hex')), decipher.final()]).toString('utf8'); + } + + isEncrypted(stored: string): boolean { + return stored.startsWith(PREFIX); + } +} + +/** + * Construit le SecretBox de production : clé dérivée d'ARBORETUM_SECRET_KEY si fournie, sinon + * d'un fichier clé `dataDir/secret.key` (0o600) généré au besoin. + */ +export function loadSecretBox(dataDir: string): SecretBox { + const passphrase = process.env.ARBORETUM_SECRET_KEY; + if (passphrase && passphrase.length > 0) { + return new SecretBox(scryptSync(passphrase, SCRYPT_SALT, 32)); + } + const keyPath = join(dataDir, 'secret.key'); + let keyHex: string; + if (existsSync(keyPath)) { + keyHex = readFileSync(keyPath, 'utf8').trim(); + } else { + keyHex = randomBytes(32).toString('hex'); + writeFileSync(keyPath, keyHex + '\n', { mode: 0o600 }); + } + try { + chmodSync(keyPath, 0o600); + } catch { + /* FS sans permissions POSIX : ignoré */ + } + return new SecretBox(Buffer.from(keyHex, 'hex')); +} diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index b8c0cda..8bd4bd3 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -1,4 +1,5 @@ import { DatabaseSync } from 'node:sqlite'; +import { chmodSync, existsSync } from 'node:fs'; const MIGRATIONS: Array<{ id: number; sql: string }> = [ { @@ -99,6 +100,24 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [ id: 6, sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`, }, + { + // Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations + // sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS + // de secret en clair — `details` est un JSON de métadonnées non sensibles. + id: 7, + sql: ` + CREATE TABLE audit_logs ( + id TEXT PRIMARY KEY, + ts TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + resource_id TEXT, + details TEXT, + result TEXT NOT NULL + ); + CREATE INDEX idx_audit_ts ON audit_logs(ts); + `, + }, ]; export type Db = DatabaseSync; @@ -107,10 +126,27 @@ export function openDb(path: string): Db { const db = new DatabaseSync(path); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); + hardenDbPermissions(path); migrate(db); return db; } +/** + * Restreint la base (et ses fichiers WAL/SHM) à 0o600 — proprio uniquement. La base contient des + * secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par + * d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX. + */ +function hardenDbPermissions(path: string): void { + if (path === ':memory:') return; + for (const p of [path, `${path}-wal`, `${path}-shm`]) { + try { + if (existsSync(p)) chmodSync(p, 0o600); + } catch { + /* FS sans permissions POSIX (Windows / montage) : ignoré */ + } + } +} + function migrate(db: DatabaseSync): void { db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); const applied = new Set( diff --git a/packages/server/src/routes/audit.ts b/packages/server/src/routes/audit.ts new file mode 100644 index 0000000..4115170 --- /dev/null +++ b/packages/server/src/routes/audit.ts @@ -0,0 +1,17 @@ +// Consultation du journal d'audit (onglet Réglages / outils de conformité). Lecture seule, sous +// l'auth globale. Pagination par curseur `before` (ts décroissant). +import type { FastifyInstance } from 'fastify'; +import type { AuditLogsResponse } from '@arboretum/shared'; +import type { Db } from '../db/index.js'; +import { listAudit } from '../core/audit-log.js'; + +export function registerAuditRoutes(app: FastifyInstance, db: Db): void { + app.get('/api/v1/audit-logs', async (req): Promise => { + const q = req.query as { limit?: string; before?: string }; + const limit = Math.min(Math.max(Math.trunc(Number(q.limit) || 50), 1), 200); + const entries = listAudit(db, { limit, before: q.before ?? null }); + // s'il reste potentiellement des entrées (page pleine), expose le curseur suivant. + const nextBefore = entries.length === limit ? entries[entries.length - 1]!.ts : null; + return { entries, nextBefore }; + }); +} diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index f42abf7..636bec7 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -8,11 +8,13 @@ import type { TokensListResponse, } from '@arboretum/shared'; import type { AuthService, LoginRateLimiter } from '../auth/service.js'; +import type { Db } from '../db/index.js'; +import { recordAudit } from '../core/audit-log.js'; // Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais // en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http // (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait). -function isHttpsRequest(req: FastifyRequest): boolean { +export function isHttpsRequest(req: FastifyRequest): boolean { const xfp = req.headers['x-forwarded-proto']; const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim(); return proto === 'https'; @@ -23,6 +25,7 @@ export function registerAuthRoutes( auth: AuthService, limiter: LoginRateLimiter, serverVersion: string, + db: Db, ): void { app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => { const wait = limiter.check(); @@ -33,9 +36,11 @@ export function registerAuthRoutes( const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null; if (!ctx) { limiter.recordFailure(); + recordAudit(db, { actor: 'anonymous', action: 'login.failure', result: 'denied' }); return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } }); } limiter.recordSuccess(); + recordAudit(db, { actor: ctx.tokenId, action: 'login.success' }); void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), { path: '/', httpOnly: true, @@ -78,18 +83,22 @@ export function registerAuthRoutes( return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } }); } const { id, token } = auth.createTokenRecord(label); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } }); return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse); }); app.delete('/api/v1/auth/tokens/:id', async (req, reply) => { const { id } = req.params as { id: string }; const result = auth.revokeToken(id); + const actor = req.authContext?.tokenId ?? 'unknown'; if (result === 'not_found') { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } }); } if (result === 'last') { + recordAudit(db, { actor, action: 'token.revoke', resourceId: id, result: 'denied', details: { reason: 'last_active_token' } }); return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } }); } + recordAudit(db, { actor, action: 'token.revoke', resourceId: id }); // 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse. return reply.send({ ok: true }); }); diff --git a/packages/server/src/routes/data.ts b/packages/server/src/routes/data.ts new file mode 100644 index 0000000..68ab406 --- /dev/null +++ b/packages/server/src/routes/data.ts @@ -0,0 +1,54 @@ +// RGPD (droits d'accès & d'effacement) : export et suppression des données rattachées au token +// authentifié. Modèle mono-utilisateur → le détenteur du token EST le sujet des données. Sous l'auth +// globale. La suppression se fait en deux temps (code de confirmation) pour éviter les clics accidentels. +import { createHash } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { DataExportResponse, DeleteMyDataRequest, DeleteMyDataResponse } from '@arboretum/shared'; +import type { Db } from '../db/index.js'; +import type { AuthService } from '../auth/service.js'; +import { readScanIntervalMin, readScanRoots } from '../core/scan-settings.js'; +import { recordAudit } from '../core/audit-log.js'; + +export function registerDataRoutes(app: FastifyInstance, db: Db, auth: AuthService): void { + app.get('/api/v1/data/export', async (req): Promise => { + const current = req.authContext!.tokenId; + const tokens = auth.listTokens().map((t) => ({ ...t, current: t.id === current })); + const pushSubscriptions = db + .prepare( + 'SELECT endpoint, user_agent AS userAgent, created_at AS createdAt, last_ok_at AS lastOkAt FROM push_subscriptions WHERE token_id = ?', + ) + .all(current) as DataExportResponse['pushSubscriptions']; + const sessions = db + .prepare( + 'SELECT id, cwd, command, title, created_at AS createdAt, ended_at AS endedAt, exit_code AS exitCode FROM sessions ORDER BY created_at', + ) + .all() as DataExportResponse['sessions']; + return { + exportedAt: new Date().toISOString(), + tokens, + pushSubscriptions, + sessions, + settings: { scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db) }, + }; + }); + + app.post('/api/v1/data/delete-my-data', async (req, reply) => { + const current = req.authContext!.tokenId; + // code déterministe lié au token (sans état serveur) : renvoyé au 1er appel, exigé au 2nd. + const code = createHash('sha256').update(`delete:${current}`).digest('hex').slice(0, 12); + const body = (req.body as DeleteMyDataRequest | null) ?? {}; + const subsCount = (db.prepare('SELECT COUNT(*) AS n FROM push_subscriptions WHERE token_id = ?').get(current) as { n: number }).n; + + if (body.confirm !== code) { + const res: DeleteMyDataResponse = { status: 'pending', confirm: code, summary: { pushSubscriptions: subsCount, tokenRevoked: false } }; + return reply.send(res); + } + + db.prepare('DELETE FROM push_subscriptions WHERE token_id = ?').run(current); + // révoque le token courant (sauf si c'est le dernier actif → garde anti lock-out conservée). + const revoked = auth.revokeToken(current) === 'ok'; + recordAudit(db, { actor: current, action: 'data.delete', details: { pushSubscriptions: subsCount, tokenRevoked: revoked } }); + const res: DeleteMyDataResponse = { status: 'done', summary: { pushSubscriptions: subsCount, tokenRevoked: revoked } }; + return reply.send(res); + }); +} diff --git a/packages/server/src/routes/groups.ts b/packages/server/src/routes/groups.ts index 6446a66..25342d5 100644 --- a/packages/server/src/routes/groups.ts +++ b/packages/server/src/routes/groups.ts @@ -7,9 +7,11 @@ import type { UpdateGroupRequest, } from '@arboretum/shared'; import type { GroupManager } from '../core/group-manager.js'; +import type { Db } from '../db/index.js'; +import { recordAudit } from '../core/audit-log.js'; import { sendManagerError } from './repos.js'; -export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void { +export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void { app.get('/api/v1/groups', async (): Promise => ({ groups: gm.listGroups() })); app.get('/api/v1/groups/:id', async (req, reply) => { @@ -33,6 +35,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi ...(body.color !== undefined ? { color: body.color } : {}), ...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}), }); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.create', resourceId: group.id, details: { label: group.label } }); return reply.status(201).send({ group } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); @@ -48,6 +51,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi ...(body.description !== undefined ? { description: body.description } : {}), ...(body.color !== undefined ? { color: body.color } : {}), }); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id }); return reply.send({ group } satisfies GroupResponse); } catch (err) { return sendManagerError(reply, err); @@ -59,6 +63,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi if (!gm.deleteGroup(id)) { return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } }); } + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.delete', resourceId: id }); return reply.send({ ok: true }); }); diff --git a/packages/server/src/routes/push.ts b/packages/server/src/routes/push.ts index 8a460af..483a49f 100644 --- a/packages/server/src/routes/push.ts +++ b/packages/server/src/routes/push.ts @@ -3,8 +3,10 @@ import type { FastifyInstance } from 'fastify'; import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared'; import type { PushService } from '../core/push-service.js'; +import type { Db } from '../db/index.js'; +import { recordAudit } from '../core/audit-log.js'; -export function registerPushRoutes(app: FastifyInstance, push: PushService): void { +export function registerPushRoutes(app: FastifyInstance, push: PushService, db: Db): void { app.get('/api/v1/push/vapid-public-key', async (): Promise => ({ key: push.publicKey() })); app.post('/api/v1/push/subscribe', async (req, reply) => { @@ -15,6 +17,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi // garanti non-null : la route est protégée par le preValidation global. const tokenId = req.authContext!.tokenId; push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null); + recordAudit(db, { actor: tokenId, action: 'push.subscribe' }); return reply.status(201).send({ ok: true }); }); @@ -24,6 +27,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } }); } push.unsubscribe(body.endpoint); + recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'push.unsubscribe' }); return reply.send({ ok: true }); }); diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index a3ad9c0..d6b75e6 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -4,8 +4,9 @@ import type { FastifyInstance } from 'fastify'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import type { Config } from '../config.js'; -import { type Db, getSetting, setSetting } from '../db/index.js'; +import { type Db, setSetting } from '../db/index.js'; import type { PushService } from '../core/push-service.js'; +import { recordAudit } from '../core/audit-log.js'; import { SCAN_INTERVAL_KEY, SCAN_ROOTS_KEY, @@ -15,23 +16,7 @@ import { readScanRoots, } from '../core/scan-settings.js'; -// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. -const GITEA_URL_KEY = 'gitea_url'; - -/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */ -function normalizeGiteaUrl(raw: string): string | null { - const trimmed = raw.trim(); - if (trimmed === '') return null; - let url: URL; - try { - url = new URL(trimmed); - } catch { - return null; - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; - return url.toString(); -} - +// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. export function registerSettingsRoutes( app: FastifyInstance, db: Db, @@ -39,8 +24,6 @@ export function registerSettingsRoutes( serverVersion: string, push: PushService, ): void { - // '' (effacé) est normalisé en null côté réponse. - const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null; const serverInfo = (): ServerInfo => ({ version: serverVersion, port: config.port, @@ -52,7 +35,6 @@ export function registerSettingsRoutes( }); const snapshot = (): SettingsResponse => ({ settings: { - giteaUrl: readGiteaUrl(), scanRoots: readScanRoots(db), scanIntervalMin: readScanIntervalMin(db), }, @@ -63,20 +45,6 @@ export function registerSettingsRoutes( app.patch('/api/v1/settings', async (req, reply) => { const body = (req.body as Partial | null) ?? {}; - if ('giteaUrl' in body) { - const v = body.giteaUrl; - if (v === null || v === '') { - setSetting(db, GITEA_URL_KEY, ''); // effacement - } else if (typeof v === 'string') { - const normalized = normalizeGiteaUrl(v); - if (!normalized) { - return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a valid http(s) URL' } }); - } - setSetting(db, GITEA_URL_KEY, normalized); - } else { - return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'giteaUrl must be a string or null' } }); - } - } if ('scanRoots' in body) { const roots = normalizeScanRoots(body.scanRoots); if (!roots) { @@ -91,6 +59,11 @@ export function registerSettingsRoutes( } setSetting(db, SCAN_INTERVAL_KEY, String(interval)); } + recordAudit(db, { + actor: req.authContext?.tokenId ?? 'unknown', + action: 'settings.update', + details: { keys: Object.keys(body) }, + }); return reply.send(snapshot()); }); } diff --git a/packages/server/test/audit-data-routes.test.ts b/packages/server/test/audit-data-routes.test.ts new file mode 100644 index 0000000..c71451f --- /dev/null +++ b/packages/server/test/audit-data-routes.test.ts @@ -0,0 +1,164 @@ +// Lot 5 — sécurité enterprise : en-têtes de sécurité, journal d'audit, RGPD (export/suppression), +// garde Content-Type. Vérifie le câblage de bout en bout via buildApp + token bootstrap. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildApp, type AppBundle } from '../src/app.js'; +import { openDb, type Db } from '../src/db/index.js'; +import type { Config } from '../src/config.js'; +import type { AuditLogsResponse, DataExportResponse, DeleteMyDataResponse } from '@arboretum/shared'; + +vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' })); +vi.mock('@homebridge/node-pty-prebuilt-multiarch', () => { + class FakePty { + pid = 424242; + write = vi.fn(); + resize = vi.fn(); + pause = vi.fn(); + resume = vi.fn(); + kill = vi.fn(); + onData(): { dispose: () => void } { + return { dispose: () => {} }; + } + onExit(): { dispose: () => void } { + return { dispose: () => {} }; + } + } + return { default: { spawn: (): FakePty => new FakePty() } }; +}); + +process.env.ARBORETUM_LOG = 'silent'; + +let dir: string; +let bundle: AppBundle; +let db: Db; +let token: string; +const auth = (): { authorization: string } => ({ authorization: `Bearer ${token}` }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'arboretum-audit-')); + const dbPath = join(dir, 'audit.db'); + db = openDb(dbPath); + const config: Config = { + port: 9998, + bind: '127.0.0.1', + dbPath, + dataDir: dir, + allowedOrigins: ['https://host.tailnet.ts.net'], + printToken: false, + claudeProjectsDir: join(dir, 'claude', 'projects'), + claudeSessionsDir: join(dir, 'claude', 'sessions'), + vapidContact: 'mailto:test@localhost', + autoDiscover: false, + }; + bundle = buildApp(config, db, '1.2.3-test'); + const t = bundle.auth.ensureBootstrapToken(); + if (!t) throw new Error('bootstrap token attendu'); + token = t; +}); + +afterAll(async () => { + await bundle.app.close(); + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('En-têtes de sécurité', () => { + it('pose les en-têtes durs + no-store sur /api, sans header Server', async () => { + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); + expect(res.statusCode).toBe(200); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['x-frame-options']).toBe('DENY'); + expect(res.headers['referrer-policy']).toBe('no-referrer'); + expect(res.headers['content-security-policy']).toContain("default-src 'self'"); + expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'"); + expect(String(res.headers['cache-control'])).toContain('no-store'); + expect(res.headers['server']).toBeUndefined(); + }); + + it('pose HSTS uniquement derrière HTTPS (x-forwarded-proto)', async () => { + const http = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); + expect(http.headers['strict-transport-security']).toBeUndefined(); + const https = await bundle.app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { ...auth(), 'x-forwarded-proto': 'https' }, + }); + expect(String(https.headers['strict-transport-security'])).toContain('max-age='); + }); + + it('rejette une mutation avec corps non-JSON (415)', async () => { + const res = await bundle.app.inject({ + method: 'POST', + url: '/api/v1/auth/tokens', + headers: { ...auth(), 'content-type': 'text/plain' }, + payload: 'label=pwned', + }); + expect(res.statusCode).toBe(415); + }); +}); + +describe('Journal d’audit', () => { + it('journalise la création de token et l’expose via GET /audit-logs', async () => { + const create = await bundle.app.inject({ + method: 'POST', + url: '/api/v1/auth/tokens', + headers: auth(), + payload: { label: 'ci-extra' }, + }); + expect(create.statusCode).toBe(201); + + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs', headers: auth() }); + expect(res.statusCode).toBe(200); + const body = res.json() as AuditLogsResponse; + const actions = body.entries.map((e) => e.action); + expect(actions).toContain('token.create'); + expect(actions).toContain('secret.generate'); // généré au bootstrap (system) + // aucune VALEUR secrète en clair : pas de chaîne hex de 64 caractères (server_secret / clé). + // (les ids sont des UUID avec tirets → non concernés ; 'server_secret' est un nom de ressource.) + expect(res.body).not.toMatch(/[a-f0-9]{64}/i); + }); + + it('journalise les échecs de login (actor anonymous)', async () => { + await bundle.app.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { token: 'arb_invalid_xxx' } }); + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/audit-logs?limit=200', headers: auth() }); + const body = res.json() as AuditLogsResponse; + expect(body.entries.some((e) => e.action === 'login.failure' && e.actor === 'anonymous')).toBe(true); + }); +}); + +describe('RGPD', () => { + it('exporte les données du token authentifié', async () => { + const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/data/export', headers: auth() }); + expect(res.statusCode).toBe(200); + const body = res.json() as DataExportResponse; + expect(Array.isArray(body.tokens)).toBe(true); + expect(body.tokens.some((t) => t.current)).toBe(true); + expect(Array.isArray(body.pushSubscriptions)).toBe(true); + expect(Array.isArray(body.sessions)).toBe(true); + expect(body.settings).toHaveProperty('scanRoots'); + }); + + it('supprime en deux temps (pending → confirm → done) et révoque le token', async () => { + const pending = await bundle.app.inject({ method: 'POST', url: '/api/v1/data/delete-my-data', headers: auth(), payload: {} }); + const p = pending.json() as DeleteMyDataResponse; + expect(p.status).toBe('pending'); + expect(p.confirm).toBeTruthy(); + + // un 2e token existe (créé plus haut) → révoquer le token courant est autorisé. + const done = await bundle.app.inject({ + method: 'POST', + url: '/api/v1/data/delete-my-data', + headers: auth(), + payload: { confirm: p.confirm }, + }); + const d = done.json() as DeleteMyDataResponse; + expect(d.status).toBe('done'); + expect(d.summary.tokenRevoked).toBe(true); + + // le token courant est désormais révoqué → 401. + const after = await bundle.app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth() }); + expect(after.statusCode).toBe(401); + }); +}); diff --git a/packages/server/test/fixtures/dialogs/ask2.raw b/packages/server/test/fixtures/dialogs/ask2.raw index 0dba71d..0aa39f2 100644 --- a/packages/server/test/fixtures/dialogs/ask2.raw +++ b/packages/server/test/fixtures/dialogs/ask2.raw @@ -19,7 +19,7 @@ -[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Johan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Beehelp│/release-notes for more│ │   /tmp/spike-s3-ask2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "fix lint errors" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  +[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Devon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Example│/release-notes for more│ │   /tmp/spike-s3-ask2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "fix lint errors" ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  [?25h[?2026l[?2026h[?25l  ⚠ 2 setup issues: MCP · /doctor   ▎ Meet Fable 5, our newest model for complex, long-running work. Try anytime with /model. ▎ Included in yourplan limits until Jun 22, then switch to usage credits to continue. @@ -321,13 +321,13 @@ [?2026l]0;⠂ Demander la couleur préférée red ou blue[?2026h╭───ClaudeCodev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ ││Tipsforgettingstarted│ -│WelcomebackJohan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ +│WelcomebackDevon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │ ▐▛███▜▌│What'snew│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │ ▘▘▝▝│Fixedaspurious"sandboxdependenciesmissing"startupwarnin…│ │Opus4.8(1Mcontext)withxh…·ClaudeTeam·│Sub-agentscannowspawntheirownsub-agents(upto5levels…│ -│Beehelp│/release-notesformore│ +│Example│/release-notesformore│ │/tmp/spike-s3-ask2││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -343,7 +343,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l ✶Bunning… @@ -359,7 +359,7 @@ [?25h[?2026l[?2026h[?25l ● User answered Claude's questions:  ⎿  · Préférez-vous le rouge ou le bleu ? → Bleu  ✢ Bunning… (4s · ↑215 tokens)  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l nn @@ -566,7 +566,7 @@ [?25h[?2026l[?2026h[?25l ●Vous avez choisile bleu 🔵 ✽ Bunning… (running stop hook · 8s · ↓ 218 tokens)  ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l]0;✳ Demander la couleur préférée red ou blue[?2026h[?25l ✻Sautéed fo 8s ❯  ? for shortcuts · ← for agents -You'veused96%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +You'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l \ No newline at end of file diff --git a/packages/server/test/fixtures/dialogs/deny-esc2.raw b/packages/server/test/fixtures/dialogs/deny-esc2.raw index 3becf32..919ace6 100644 --- a/packages/server/test/fixtures/dialogs/deny-esc2.raw +++ b/packages/server/test/fixtures/dialogs/deny-esc2.raw @@ -19,7 +19,7 @@ -[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Johan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Beehelp│/release-notes for more│ │   /tmp/spike-s3-deny2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "create a util logging.py that..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  +[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Devon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Example│/release-notes for more│ │   /tmp/spike-s3-deny2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "create a util logging.py that..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  [?25h[?2026l[?2026h[?25l  ⚠ 2 setup issues: MCP · /doctor   ▎ Meet Fable 5, our newest model for complex, long-running work. Try anytime with /model. ▎ Included in yourplan limits until Jun 22, then switch to usage credits to continue. @@ -475,7 +475,7 @@ [?25h[?2026l]0;✳ Execute Node.js command in bash[?2026h[?25l ✻Churned for 5s ❯  ? for shortcuts · ← for agents [?25h[?2026l[?2026h[?25l  -You'veused94%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +You'veused94%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l[?25h[?2026l[?1006l[?1003l[?1002l[?1000l(B[>4m[ to..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  +[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Devon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Example│/release-notes for more│ │   /tmp/spike-s3-bash2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "edit to..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  [?25h[?2026l[?2026h[?25l  ⚠ 2 setup issues: MCP · /doctor   ▎ Meet Fable 5, our newest model for complex, long-running work. Try anytime with /model. ▎ Included in yourplan limits until Jun 22, then switch to usage credits to continue. diff --git a/packages/server/test/fixtures/dialogs/perm-write2.raw b/packages/server/test/fixtures/dialogs/perm-write2.raw index 2a81344..3fb4d05 100644 --- a/packages/server/test/fixtures/dialogs/perm-write2.raw +++ b/packages/server/test/fixtures/dialogs/perm-write2.raw @@ -19,7 +19,7 @@ -[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Johan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Beehelp│/release-notes for more│ │   /tmp/spike-s3-write2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "edit to..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  +[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Devon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Example│/release-notes for more│ │   /tmp/spike-s3-write2    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "edit to..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  [?25h[?2026l[?2026h[?25l  ⚠ 2 setup issues: MCP · /doctor   ▎ Meet Fable 5, our newest model for complex, long-running work. Try anytime with /model. ▎ Included in yourplan limits until Jun 22, then switch to usage credits to continue. @@ -218,13 +218,13 @@ Esctocancel·Tabtoamend [?2026l]0;⠐ Créer un fichier s3-edit.txt avec hello[?2026h╭───ClaudeCodev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ ││Tipsforgettingstarted│ -│WelcomebackJohan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ +│WelcomebackDevon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │ ▐▛███▜▌│What'snew│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │ ▘▘▝▝│Fixedaspurious"sandboxdependenciesmissing"startupwarnin…│ │Opus4.8(1Mcontext)withxh…·ClaudeTeam·│Sub-agentscannowspawntheirownsub-agents(upto5levels…│ -│Beehelp│/release-notesformore│ +│Example│/release-notesformore│ │/tmp/spike-s3-write2││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -242,7 +242,7 @@ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l ✶Seasoning… @@ -251,7 +251,7 @@ [?25h[?2026l[?2026h[?25l ●  ⎿  Wrote1linestos3-edit.txt    1 hello ✶ Seasoning… (3s · ↑ 147 tokens)  ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l i… @@ -416,7 +416,7 @@ [?25h[?2026l]0;⠂ Créer un fichier s3-edit.txt avec hello[?2026h[?25l ●Fichier s3-edit.txt créé avec lecontenuhello. · Seasoning… (5s · ↓ 147 tokens)  ❯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l ✢g69 @@ -430,16 +430,16 @@ [?25h[?2026l]0;✳ Créer un fichier s3-edit.txt avec hello[?2026h[?25l ✻Cooked for 6s ❯  ? for shortcuts · ← for agents -You'veused91%ofyoursessionlimit·resets7pm(Europe/Paris)·/usage-creditstorequestmore +You'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore [?25h[?2026l[?2026h[?25l╭───ClaudeCodev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ ││Tipsforgettingstarted│ -│WelcomebackJohan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ +│WelcomebackDevon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │ ▐▛███▜▌│What'snew│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │ ▘▘▝▝│Fixedaspurious"sandboxdependenciesmissing"startupwarnin…│ │Opus4.8(1Mcontext)withxh…·ClaudeTeam·│Sub-agentscannowspawntheirownsub-agents(upto5levels…│ -│Beehelp│/release-notesformore│ +│Example│/release-notesformore│ │/tmp/spike-s3-write2││ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/packages/server/test/fixtures/dialogs/trust.raw b/packages/server/test/fixtures/dialogs/trust.raw index cc96f2c..5059e96 100644 --- a/packages/server/test/fixtures/dialogs/trust.raw +++ b/packages/server/test/fixtures/dialogs/trust.raw @@ -19,7 +19,7 @@ -[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Johan!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Beehelp│/release-notes for more│ │   /tmp/spike-s3-trust    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "create a util logging.py that..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  +[?2026l]0;✳ Claude Code[?2026h ╭───Claude Codev2.1.173─────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips for getting started │ │ Welcome back Devon!│Run/inittocreateaCLAUDE.mdfilewithinstructionsforCla…│ ││───────────────────────────────────────────────────────────────│ │  ▐▛███▜▌│What's new│ │▝▜█████▛▘│FixedFable5modelnameswitha`[1m]`suffixnotbeingnorma…│ │     ▘▘ ▝▝     │ Fixed a spurious "sandbox depenncies missing"startup warnin…│ │ Opus 4.8 (1M context) with xh… · Claude Team ·  │ Sub-agnts can nowspawn their own sub-agents(upto5levels…│ │Example│/release-notes for more│ │   /tmp/spike-s3-trust    │  │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯  ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ❯ Try "create a util logging.py that..." ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ? for shortcuts · ← for agents◉ xhigh · /effort [?25h[?2026l[?2026h[?25l  [?25h[?2026l[?2026h[?25l  ⚠ 2 setup issues: MCP · /doctor   ▎ Meet Fable 5, our newest model for complex, long-running work. Try anytime with /model. ▎ Included in yourplan limits until Jun 22, then switch to usage credits to continue. diff --git a/packages/server/test/jsonl-discovery.test.ts b/packages/server/test/jsonl-discovery.test.ts index 825eb6d..8014f76 100644 --- a/packages/server/test/jsonl-discovery.test.ts +++ b/packages/server/test/jsonl-discovery.test.ts @@ -13,7 +13,7 @@ function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object describe('munge', () => { it('reproduit le nom de dossier ~/.claude/projects', () => { expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-'); - expect(munge('/home/johan/WebstormProjects/arboretum')).toBe('-home-johan-WebstormProjects-arboretum'); + expect(munge('/home/user/projects/demo')).toBe('-home-user-projects-demo'); }); }); diff --git a/packages/server/test/repo-scanner.test.ts b/packages/server/test/repo-scanner.test.ts index b453c85..fed9116 100644 --- a/packages/server/test/repo-scanner.test.ts +++ b/packages/server/test/repo-scanner.test.ts @@ -38,6 +38,16 @@ describe('scanForRepos', () => { expect(paths).toEqual([join(root, 'a')]); }); + it('descend dans une racine qui est elle-même un repo (workspace + sous-repos)', async () => { + const root = tmpRoot(); + makeRepo(root); // la racine fournie contient elle-même un .git (cas WebstormProjects) + makeRepo(root, 'a'); + makeRepo(root, 'b'); + const { paths } = await scanForRepos([root], limits); + // la racine ET ses dépôts internes sont découverts (la racine n'arrête pas le scan) + expect([...paths].sort()).toEqual([root, join(root, 'a'), join(root, 'b')].sort()); + }); + it('ignore node_modules et les dotdirs', async () => { const root = tmpRoot(); makeRepo(root, 'node_modules', 'pkg'); diff --git a/packages/server/test/secret-box.test.ts b/packages/server/test/secret-box.test.ts new file mode 100644 index 0000000..c404217 --- /dev/null +++ b/packages/server/test/secret-box.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { SecretBox } from '../src/core/secret-box.js'; + +const box = (): SecretBox => new SecretBox(randomBytes(32)); + +describe('SecretBox', () => { + it('chiffre puis déchiffre (round-trip)', () => { + const b = box(); + const secret = 'deadbeef'.repeat(8); + const enc = b.encrypt(secret); + expect(enc.startsWith('v1:')).toBe(true); + expect(enc).not.toContain(secret); // le clair n'apparaît pas + expect(b.decrypt(enc)).toBe(secret); + }); + + it('produit un chiffré différent à chaque fois (IV aléatoire)', () => { + const b = box(); + expect(b.encrypt('x')).not.toBe(b.encrypt('x')); + }); + + it('retourne tel quel une valeur en clair (legacy) et la détecte', () => { + const b = box(); + expect(b.isEncrypted('plain-secret')).toBe(false); + expect(b.decrypt('plain-secret')).toBe('plain-secret'); + expect(b.isEncrypted(b.encrypt('x'))).toBe(true); + }); + + it('échoue si la clé ne correspond pas (GCM authentifié)', () => { + const enc = box().encrypt('secret'); + expect(() => box().decrypt(enc)).toThrow(); + }); + + it('échoue sur un format chiffré corrompu', () => { + expect(() => box().decrypt('v1:zzz')).toThrow(); + }); +}); diff --git a/packages/server/test/settings-routes.test.ts b/packages/server/test/settings-routes.test.ts index b244af7..3383066 100644 --- a/packages/server/test/settings-routes.test.ts +++ b/packages/server/test/settings-routes.test.ts @@ -1,5 +1,5 @@ // Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que -// l'allow-list et valide l'URL Gitea (http/https only, anti-XSS). +// l'allow-list (découverte des dépôts) et ignore toute clé hors allow-list. import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -65,7 +65,7 @@ afterAll(async () => { }); describe('GET /api/v1/settings', () => { - it('renvoie la config serveur non sensible et giteaUrl null par défaut', async () => { + it('renvoie la config serveur non sensible et aucune racine de scan par défaut', async () => { const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() }); expect(res.statusCode).toBe(200); const body = res.json() as SettingsResponse; @@ -74,10 +74,8 @@ describe('GET /api/v1/settings', () => { expect(body.server.bind).toBe('127.0.0.1'); expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']); expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer - expect(body.settings.giteaUrl).toBeNull(); - // défauts de découverte : home (1 racine) + intervalle 5 min - expect(Array.isArray(body.settings.scanRoots)).toBe(true); - expect(body.settings.scanRoots).toHaveLength(1); + // défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min + expect(body.settings.scanRoots).toEqual([]); expect(body.settings.scanIntervalMin).toBe(5); }); @@ -93,29 +91,7 @@ describe('GET /api/v1/settings', () => { }); }); -describe('PATCH /api/v1/settings', () => { - it('enregistre une URL Gitea valide et la renvoie', async () => { - const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } }); - expect(res.statusCode).toBe(200); - expect((res.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/'); - // persisté - const get = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() }); - expect((get.json() as SettingsResponse).settings.giteaUrl).toBe('https://git.lidge.fr/'); - }); - - it('efface l’URL avec null ou chaîne vide', async () => { - await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'https://git.lidge.fr' } }); - const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: null } }); - expect((res.json() as SettingsResponse).settings.giteaUrl).toBeNull(); - }); - - it('rejette une URL non http(s) — anti-XSS (400)', async () => { - const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'javascript:alert(1)' } }); - expect(res.statusCode).toBe(400); - const notUrl = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { giteaUrl: 'pas une url' } }); - expect(notUrl.statusCode).toBe(400); - }); - +describe('PATCH /api/v1/settings — sécurité', () => { it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => { const before = getSetting(db, 'server_secret'); await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { server_secret: 'pwned', vapid_private: 'pwned' } }); @@ -124,7 +100,7 @@ describe('PATCH /api/v1/settings', () => { }); it('sans authentification → 401', async () => { - const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { giteaUrl: 'https://x.example' } }); + const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', payload: { scanRoots: ['/home/u/work'] } }); expect(res.statusCode).toBe(401); }); }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 39042c9..d835100 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -199,8 +199,7 @@ export interface ServerInfo { export interface SettingsResponse { /** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */ settings: { - giteaUrl: string | null; - /** racines absolues scannées pour la découverte auto des repos (défaut : home). */ + /** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */ scanRoots: string[]; /** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */ scanIntervalMin: number; @@ -208,10 +207,48 @@ export interface SettingsResponse { server: ServerInfo; } export interface UpdateSettingsRequest { - /** URL de l'instance Gitea (http/https) ; null ou '' pour effacer. */ - giteaUrl?: string | null; /** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */ scanRoots?: string[]; /** intervalle du re-scan périodique en minutes (0–1440 ; 0 désactive). */ scanIntervalMin?: number; } + +// ---- Journal d'audit (conformité entreprise) ---- +export interface AuditLogEntry { + id: string; + /** horodatage ISO 8601 */ + ts: string; + /** tokenId de l'acteur, 'system' (auto) ou 'anonymous' (avant auth) */ + actor: string; + /** verbe.objet, ex. 'token.create', 'settings.update' */ + action: string; + resourceId: string | null; + /** métadonnées non sensibles (jamais de secret) */ + details: unknown; + result: string; +} +export interface AuditLogsResponse { + entries: AuditLogEntry[]; + /** curseur de pagination (ts à passer en `before`) ; null si fin de liste. */ + nextBefore: string | null; +} + +// ---- RGPD : export / suppression des données liées au token authentifié ---- +export interface DataExportResponse { + exportedAt: string; + tokens: Array<{ id: string; label: string; createdAt: string; lastUsedAt: string | null; current: boolean }>; + pushSubscriptions: Array<{ endpoint: string; userAgent: string | null; createdAt: string; lastOkAt: string | null }>; + sessions: Array<{ id: string; cwd: string; command: string; title: string | null; createdAt: string; endedAt: string | null; exitCode: number | null }>; + settings: { scanRoots: string[]; scanIntervalMin: number }; +} +export interface DeleteMyDataRequest { + /** code de confirmation renvoyé par un premier appel sans `confirm` ; doit être renvoyé pour exécuter. */ + confirm?: string; +} +export interface DeleteMyDataResponse { + /** 'pending' = confirmation requise (renvoie `confirm`) ; 'done' = suppression effectuée. */ + status: 'pending' | 'done'; + confirm?: string; + /** récapitulatif de ce qui sera/a été supprimé. */ + summary: { pushSubscriptions: number; tokenRevoked: boolean }; +} diff --git a/packages/web/src/composables/useNav.ts b/packages/web/src/composables/useNav.ts index d32d8fd..8fd9609 100644 --- a/packages/web/src/composables/useNav.ts +++ b/packages/web/src/composables/useNav.ts @@ -3,8 +3,8 @@ import { useRoute, type RouteLocationRaw } from 'vue-router'; import { useI18n } from 'vue-i18n'; import { Boxes, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue'; import { useSessionsStore } from '../stores/sessions'; -import { useSettingsStore } from '../stores/settings'; import GiteaIcon from '../components/ui/GiteaIcon.vue'; +import { REPO_SOURCE_URL } from '../lib/constants'; export interface NavEntry { key: string; @@ -23,7 +23,6 @@ export function useNav() { const { t } = useI18n(); const route = useRoute(); const sessions = useSessionsStore(); - const settings = useSettingsStore(); // sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile). const waitingCount = computed( @@ -37,17 +36,12 @@ export function useNav() { { key: 'groups', to: { name: 'groups' }, icon: Boxes, label: t('nav.groups'), match: ['groups', 'group'], badge: 0 }, ]); - // Onglets secondaires : réglages, aide, et lien externe Gitea (uniquement si l'URL est configurée). - const secondary = computed(() => { - const items: NavEntry[] = [ - { key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 }, - { key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 }, - ]; - if (settings.giteaUrl) { - items.push({ key: 'gitea', href: settings.giteaUrl, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 }); - } - return items; - }); + // Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent). + const secondary = computed(() => [ + { key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 }, + { key: 'help', to: { name: 'help' }, icon: LifeBuoy, label: t('nav.help'), match: ['help'], badge: 0 }, + { key: 'gitea', href: REPO_SOURCE_URL, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 }, + ]); const isActive = (match: string[]): boolean => match.includes(String(route.name)); diff --git a/packages/web/src/i18n/en.ts b/packages/web/src/i18n/en.ts index 7d2922f..b76486e 100644 --- a/packages/web/src/i18n/en.ts +++ b/packages/web/src/i18n/en.ts @@ -181,7 +181,7 @@ export default { settings: 'Settings', help: 'Help', more: 'More', - gitea: 'Open Gitea', + gitea: 'Source code', waiting: 'waiting', }, controls: { @@ -333,14 +333,16 @@ export default { confirmRevoke: 'Confirm revoke', tokenRevoked: 'Token revoked', lastTokenError: 'You cannot revoke the last active token — create another one first.', - // Intégrations - integrations: 'Integrations', - gitea: 'Gitea', - giteaUrlLabel: 'Gitea instance URL', - giteaUrlPlaceholder: 'https://git.example.com', - giteaUrlHint: 'Adds a shortcut icon to the navigation. Leave empty to hide it.', save: 'Save', saved: 'Saved', + // Sécurité & conformité + compliance: 'Security & compliance', + complianceHint: 'Export or erase the data tied to your token (GDPR), and review the audit log of sensitive operations.', + exportData: 'Export my data', + refreshAudit: 'Audit log', + deleteData: 'Erase my data', + deleteConfirm: 'Erase the data tied to your token (push subscriptions) and revoke this token? This cannot be undone.', + deleteDone: 'Your data has been erased.', // Découverte des dépôts discovery: 'Repository discovery', discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.', diff --git a/packages/web/src/i18n/fr.ts b/packages/web/src/i18n/fr.ts index 8ac3c0c..3d4e3ae 100644 --- a/packages/web/src/i18n/fr.ts +++ b/packages/web/src/i18n/fr.ts @@ -184,7 +184,7 @@ const fr: typeof en = { settings: 'Réglages', help: 'Aide', more: 'Plus', - gitea: 'Ouvrir Gitea', + gitea: 'Code source', waiting: 'en attente', }, controls: { @@ -336,14 +336,16 @@ const fr: typeof en = { confirmRevoke: 'Confirmer la révocation', tokenRevoked: 'Jeton révoqué', lastTokenError: 'Impossible de révoquer le dernier jeton actif — créez-en un autre d’abord.', - // Intégrations - integrations: 'Intégrations', - gitea: 'Gitea', - giteaUrlLabel: 'URL de l’instance Gitea', - giteaUrlPlaceholder: 'https://git.exemple.com', - giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.', save: 'Enregistrer', saved: 'Enregistré', + // Sécurité & conformité + compliance: 'Sécurité & conformité', + complianceHint: 'Exportez ou effacez les données liées à votre jeton (RGPD), et consultez le journal d’audit des opérations sensibles.', + exportData: 'Exporter mes données', + refreshAudit: 'Journal d’audit', + deleteData: 'Effacer mes données', + deleteConfirm: 'Effacer les données liées à votre jeton (abonnements push) et révoquer ce jeton ? Action irréversible.', + deleteDone: 'Vos données ont été effacées.', // Découverte des dépôts discovery: 'Découverte des dépôts', discoveryHint: 'Arboretum scanne ces dossiers à la recherche de dépôts git et les enregistre automatiquement. Les dépôts masqués sont conservés et jamais ré-ajoutés.', diff --git a/packages/web/src/lib/constants.ts b/packages/web/src/lib/constants.ts new file mode 100644 index 0000000..8693503 --- /dev/null +++ b/packages/web/src/lib/constants.ts @@ -0,0 +1,8 @@ +// Constantes globales du front. + +/** + * Lien « Code source » affiché en permanence dans la navigation secondaire. + * En dur (pas un réglage) : pointe vers le dépôt source d'Arboretum, identique pour + * tous les utilisateurs de la solution. + */ +export const REPO_SOURCE_URL = 'https://git.lidge.fr/johanleroy/arboretum'; diff --git a/packages/web/src/stores/settings.ts b/packages/web/src/stores/settings.ts index c3b3474..5654e1b 100644 --- a/packages/web/src/stores/settings.ts +++ b/packages/web/src/stores/settings.ts @@ -3,11 +3,10 @@ import { ref } from 'vue'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import { api } from '../lib/api'; -// Réglages serveur + intégrations. `giteaUrl` alimente l'item de nav Gitea (affiché si défini). +// Réglages serveur (découverte des dépôts, infos serveur). // Les préférences purement client (langue) restent gérées par l'i18n/localStorage. export const useSettingsStore = defineStore('settings', () => { const server = ref(null); - const giteaUrl = ref(null); const scanRoots = ref([]); const scanIntervalMin = ref(0); const loaded = ref(false); @@ -15,7 +14,6 @@ export const useSettingsStore = defineStore('settings', () => { function apply(res: SettingsResponse): void { server.value = res.server; - giteaUrl.value = res.settings.giteaUrl; scanRoots.value = res.settings.scanRoots; scanIntervalMin.value = res.settings.scanIntervalMin; loaded.value = true; @@ -34,5 +32,5 @@ export const useSettingsStore = defineStore('settings', () => { } } - return { server, giteaUrl, scanRoots, scanIntervalMin, loaded, saving, fetch, save }; + return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save }; }); diff --git a/packages/web/src/views/SettingsView.vue b/packages/web/src/views/SettingsView.vue index aa377d6..6a72867 100644 --- a/packages/web/src/views/SettingsView.vue +++ b/packages/web/src/views/SettingsView.vue @@ -102,25 +102,6 @@ - -
-

- {{ t('settings.integrations') }} -

-
- -

{{ t('settings.giteaUrlHint') }}

-
-
-

@@ -156,6 +137,26 @@

+ +
+

+ {{ t('settings.compliance') }} +

+

{{ t('settings.complianceHint') }}

+
+ {{ t('settings.exportData') }} + {{ t('settings.refreshAudit') }} + {{ t('settings.deleteData') }} +
+
    +
  • + {{ e.action }} + {{ e.actor }} + {{ new Date(e.ts).toLocaleString() }} +
  • +
+
+

@@ -183,8 +184,16 @@