feat: clean install (Gitea en dur, scan off) + durcissement sécurité entreprise

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 16:44:25 +02:00
parent 77f00268ec
commit 7bb274446c
41 changed files with 973 additions and 338 deletions

View File

@@ -47,17 +47,14 @@ jobs:
- name: Pack tarball - name: Pack tarball
run: | run: |
rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs rm -rf /tmp/tarballs && mkdir -p /tmp/tarballs
# Le hook prepack (copy-web + vendor-shared + copy-meta) matérialise # @arboretum/shared (dépendance runtime non publiée) est embarquée via bundleDependencies,
# @arboretum/shared comme VRAI dossier dans le node_modules du serveur — # matérialisée par le hook prepack (copy-web + vendor-shared + copy-meta) comme VRAI dossier
# condition pour que npm l'embarque via bundleDependencies. Selon la version # dans packages/server/node_modules/@arboretum/shared.
# de npm du runner, « npm pack -w » ne déclenche PAS toujours ce lifecycle # On packe depuis le CONTEXTE DU PACKAGE (cd), PAS en mode workspace (-w) : « npm pack -w »
# (contrairement à « npm publish -w », qui lance bien prepack). On l'exécute # résout @arboretum/shared via le symlink workspace hoisté de la racine et n'embarque donc
# donc explicitement, on vérifie la matérialisation, puis on packe avec # PAS le dossier vendored local. « cd packages/server && npm pack » lance le lifecycle pack
# --ignore-scripts (prepack déjà fait, dossier physique présent → bundlé). # complet et reproduit fidèlement « npm publish » (qui, lui, embarque correctement).
npm run prepack -w @johanleroy/git-arboretum ( cd packages/server && npm pack --pack-destination /tmp/tarballs )
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
ls -l /tmp/tarballs ls -l /tmp/tarballs
- name: Assert @arboretum/shared is bundled in the tarball - name: Assert @arboretum/shared is bundled in the tarball
run: | run: |

View File

@@ -40,3 +40,10 @@ jobs:
# Mapper le secret du registre (PAT Gitea write:package, ou le token auto # 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. # GITEA_TOKEN s'il a ce scope) sur NODE_AUTH_TOKEN lu par le .npmrc de setup-node.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 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

View File

@@ -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. - 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). - 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. - 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. 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 ## Ce qui le distingue
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |

View File

@@ -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. - 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). - 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. - 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. 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 ## What makes it different
| | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control | | | Arboretum | GitKraken Agent Mode / Conductor / Nimbalyst | Happy / CloudCLI | Anthropic Remote Control |

61
SECURITY.md Normal file
View File

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

View File

@@ -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='<a long random passphrase from your secrets manager>' 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.

View File

@@ -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 fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket'; import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
@@ -14,6 +14,7 @@ import { WorktreeManager } from './core/worktree-manager.js';
import { RepoDiscoveryService } from './core/repo-discovery.js'; import { RepoDiscoveryService } from './core/repo-discovery.js';
import { GroupManager } from './core/group-manager.js'; import { GroupManager } from './core/group-manager.js';
import { PushService } from './core/push-service.js'; import { PushService } from './core/push-service.js';
import { loadSecretBox } from './core/secret-box.js';
import { registerAuthRoutes } from './routes/auth.js'; import { registerAuthRoutes } from './routes/auth.js';
import { registerSessionRoutes } from './routes/sessions.js'; import { registerSessionRoutes } from './routes/sessions.js';
import { registerRepoRoutes } from './routes/repos.js'; import { registerRepoRoutes } from './routes/repos.js';
@@ -22,7 +23,37 @@ import { registerWorktreeRoutes } from './routes/worktrees.js';
import { registerPushRoutes } from './routes/push.js'; import { registerPushRoutes } from './routes/push.js';
import { registerSettingsRoutes } from './routes/settings.js'; import { registerSettingsRoutes } from './routes/settings.js';
import { registerFsRoutes } from './routes/fs.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 { 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<string, string> = {
'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' { declare module 'fastify' {
interface FastifyRequest { interface FastifyRequest {
@@ -46,9 +77,11 @@ export interface AppBundle {
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle { export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } }); 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 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 manager = new PtyManager(db, config.claudeSessionsDir, push);
const discovery = new DiscoveryService({ const discovery = new DiscoveryService({
ptyManager: manager, ptyManager: manager,
@@ -60,6 +93,43 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
const repoDiscovery = new RepoDiscoveryService(db, worktrees); const repoDiscovery = new RepoDiscoveryService(db, worktrees);
const groups = new GroupManager(db); 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(fastifyCookie);
void app.register(fastifyWebsocket, { void app.register(fastifyWebsocket, {
options: { maxPayload: 1024 * 1024 }, 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); registerSessionRoutes(app, manager, discovery);
registerRepoRoutes(app, worktrees, db); registerRepoRoutes(app, worktrees, db);
registerGroupRoutes(app, groups); registerGroupRoutes(app, groups, db);
registerWorktreeRoutes(app, worktrees); registerWorktreeRoutes(app, worktrees);
registerPushRoutes(app, push); registerPushRoutes(app, push, db);
registerSettingsRoutes(app, db, config, serverVersion, push); registerSettingsRoutes(app, db, config, serverVersion, push);
registerFsRoutes(app); registerFsRoutes(app);
registerAuditRoutes(app, db);
registerDataRoutes(app, db, auth);
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte // 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). // encapsulé) — sinon le handler reçoit la signature REST (request, reply).
void app.register(async (scoped) => { void app.register(async (scoped) => {

View File

@@ -1,5 +1,7 @@
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { type Db, getSetting, setSetting } from '../db/index.js'; 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_NAME = 'arb_session';
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000; const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
@@ -12,11 +14,21 @@ export interface AuthContext {
export class AuthService { export class AuthService {
private readonly secret: Buffer; private readonly secret: Buffer;
constructor(private readonly db: Db) { constructor(
let secretHex = getSetting(db, 'server_secret'); private readonly db: Db,
if (!secretHex) { 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'); 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'); this.secret = Buffer.from(secretHex, 'hex');
} }

View File

@@ -1,7 +1,7 @@
import { parseArgs } from 'node:util'; import { parseArgs } from 'node:util';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { mkdirSync } from 'node:fs'; import { chmodSync, mkdirSync } from 'node:fs';
export interface Config { export interface Config {
port: number; 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'); const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
mkdirSync(dataDir, { recursive: true }); 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'); const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
return { return {
port: Number(values.port), port: Number(values.port),

View File

@@ -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<string, unknown> | 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;
}
}

View File

@@ -4,6 +4,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { type Db, getSetting, setSetting } from '../db/index.js'; 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), // 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. // 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 db: Db,
private readonly contact: string = 'mailto:arboretum@localhost', private readonly contact: string = 'mailto:arboretum@localhost',
sender?: PushSender, sender?: PushSender,
box?: SecretBox,
) { ) {
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2])); this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
let pub = getSetting(db, 'vapid_public'); let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer)
let priv = getSetting(db, 'vapid_private'); const storedPriv = getSetting(db, 'vapid_private');
let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null;
if (!pub || !priv) { if (!pub || !priv) {
const keys = webpush.generateVAPIDKeys(); const keys = webpush.generateVAPIDKeys();
pub = keys.publicKey; pub = keys.publicKey;
priv = keys.privateKey; priv = keys.privateKey;
setSetting(db, 'vapid_public', pub); 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.vapidPublic = pub;
this.vapidPrivate = priv; this.vapidPrivate = priv;

View File

@@ -37,7 +37,9 @@ interface Frame {
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`. * Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
* Règles : * Règles :
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo : * - 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 * - 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 ; * suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ; * - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
@@ -68,10 +70,13 @@ export async function scanForRepos(
if (seen.has(dir)) continue; if (seen.has(dir)) continue;
seen.add(dir); 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'))) { if (existsSync(join(dir, '.git'))) {
found.add(dir); found.add(dir);
continue; if (depth > 0) continue;
} }
if (depth >= limits.maxDepth) continue; if (depth >= limits.maxDepth) continue;

View File

@@ -1,7 +1,6 @@
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur). // 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 // 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. // /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 { getSetting } from '../db/index.js';
import type { Db } from '../db/index.js'; import type { Db } from '../db/index.js';
import { isSafeAbsolutePath } from './git.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_INTERVAL_MIN = 1440;
export const MAX_SCAN_ROOTS = 16; 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[] { export function readScanRoots(db: Db): string[] {
const raw = getSetting(db, SCAN_ROOTS_KEY); const raw = getSetting(db, SCAN_ROOTS_KEY);
if (!raw) return [homedir()]; if (!raw) return [];
const parsed = normalizeScanRoots(safeParse(raw)); return normalizeScanRoots(safeParse(raw)) ?? [];
return parsed && parsed.length > 0 ? parsed : [homedir()];
} }
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */ /** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */

View File

@@ -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:<iv>:<tag>:<ciphertext>` (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'));
}

View File

@@ -1,4 +1,5 @@
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import { chmodSync, existsSync } from 'node:fs';
const MIGRATIONS: Array<{ id: number; sql: string }> = [ const MIGRATIONS: Array<{ id: number; sql: string }> = [
{ {
@@ -99,6 +100,24 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
id: 6, id: 6,
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`, 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; export type Db = DatabaseSync;
@@ -107,10 +126,27 @@ export function openDb(path: string): Db {
const db = new DatabaseSync(path); const db = new DatabaseSync(path);
db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA foreign_keys = ON');
hardenDbPermissions(path);
migrate(db); migrate(db);
return 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 { function migrate(db: DatabaseSync): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
const applied = new Set( const applied = new Set(

View File

@@ -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<AuditLogsResponse> => {
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 };
});
}

View File

@@ -8,11 +8,13 @@ import type {
TokensListResponse, TokensListResponse,
} from '@arboretum/shared'; } from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js'; 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 // 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 // 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). // (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 xfp = req.headers['x-forwarded-proto'];
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim(); const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
return proto === 'https'; return proto === 'https';
@@ -23,6 +25,7 @@ export function registerAuthRoutes(
auth: AuthService, auth: AuthService,
limiter: LoginRateLimiter, limiter: LoginRateLimiter,
serverVersion: string, serverVersion: string,
db: Db,
): void { ): void {
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => { app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
const wait = limiter.check(); const wait = limiter.check();
@@ -33,9 +36,11 @@ export function registerAuthRoutes(
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null; const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
if (!ctx) { if (!ctx) {
limiter.recordFailure(); limiter.recordFailure();
recordAudit(db, { actor: 'anonymous', action: 'login.failure', result: 'denied' });
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } }); return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
} }
limiter.recordSuccess(); limiter.recordSuccess();
recordAudit(db, { actor: ctx.tokenId, action: 'login.success' });
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), { void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
@@ -78,18 +83,22 @@ export function registerAuthRoutes(
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 164 characters' } }); return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 164 characters' } });
} }
const { id, token } = auth.createTokenRecord(label); 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); return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
}); });
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => { app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const result = auth.revokeToken(id); const result = auth.revokeToken(id);
const actor = req.authContext?.tokenId ?? 'unknown';
if (result === 'not_found') { if (result === 'not_found') {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } }); return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
} }
if (result === 'last') { 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' } }); 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. // 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });

View File

@@ -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<DataExportResponse> => {
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);
});
}

View File

@@ -7,9 +7,11 @@ import type {
UpdateGroupRequest, UpdateGroupRequest,
} from '@arboretum/shared'; } from '@arboretum/shared';
import type { GroupManager } from '../core/group-manager.js'; 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'; 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<GroupsListResponse> => ({ groups: gm.listGroups() })); app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
app.get('/api/v1/groups/:id', async (req, reply) => { 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 } : {}), ...(body.color !== undefined ? { color: body.color } : {}),
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}), ...(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); return reply.status(201).send({ group } satisfies GroupResponse);
} catch (err) { } catch (err) {
return sendManagerError(reply, err); return sendManagerError(reply, err);
@@ -48,6 +51,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
...(body.description !== undefined ? { description: body.description } : {}), ...(body.description !== undefined ? { description: body.description } : {}),
...(body.color !== undefined ? { color: body.color } : {}), ...(body.color !== undefined ? { color: body.color } : {}),
}); });
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id });
return reply.send({ group } satisfies GroupResponse); return reply.send({ group } satisfies GroupResponse);
} catch (err) { } catch (err) {
return sendManagerError(reply, err); return sendManagerError(reply, err);
@@ -59,6 +63,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
if (!gm.deleteGroup(id)) { if (!gm.deleteGroup(id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this 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 }); return reply.send({ ok: true });
}); });

View File

@@ -3,8 +3,10 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared'; import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
import type { PushService } from '../core/push-service.js'; 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<VapidKeyResponse> => ({ key: push.publicKey() })); app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
app.post('/api/v1/push/subscribe', async (req, reply) => { 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. // garanti non-null : la route est protégée par le preValidation global.
const tokenId = req.authContext!.tokenId; 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); 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 }); 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' } }); return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
} }
push.unsubscribe(body.endpoint); push.unsubscribe(body.endpoint);
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'push.unsubscribe' });
return reply.send({ ok: true }); return reply.send({ ok: true });
}); });

View File

@@ -4,8 +4,9 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
import type { Config } from '../config.js'; 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 type { PushService } from '../core/push-service.js';
import { recordAudit } from '../core/audit-log.js';
import { import {
SCAN_INTERVAL_KEY, SCAN_INTERVAL_KEY,
SCAN_ROOTS_KEY, SCAN_ROOTS_KEY,
@@ -15,23 +16,7 @@ import {
readScanRoots, readScanRoots,
} from '../core/scan-settings.js'; } from '../core/scan-settings.js';
// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici. // Réglages 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();
}
export function registerSettingsRoutes( export function registerSettingsRoutes(
app: FastifyInstance, app: FastifyInstance,
db: Db, db: Db,
@@ -39,8 +24,6 @@ export function registerSettingsRoutes(
serverVersion: string, serverVersion: string,
push: PushService, push: PushService,
): void { ): void {
// '' (effacé) est normalisé en null côté réponse.
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
const serverInfo = (): ServerInfo => ({ const serverInfo = (): ServerInfo => ({
version: serverVersion, version: serverVersion,
port: config.port, port: config.port,
@@ -52,7 +35,6 @@ export function registerSettingsRoutes(
}); });
const snapshot = (): SettingsResponse => ({ const snapshot = (): SettingsResponse => ({
settings: { settings: {
giteaUrl: readGiteaUrl(),
scanRoots: readScanRoots(db), scanRoots: readScanRoots(db),
scanIntervalMin: readScanIntervalMin(db), scanIntervalMin: readScanIntervalMin(db),
}, },
@@ -63,20 +45,6 @@ export function registerSettingsRoutes(
app.patch('/api/v1/settings', async (req, reply) => { app.patch('/api/v1/settings', async (req, reply) => {
const body = (req.body as Partial<UpdateSettingsRequest> | null) ?? {}; const body = (req.body as Partial<UpdateSettingsRequest> | 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) { if ('scanRoots' in body) {
const roots = normalizeScanRoots(body.scanRoots); const roots = normalizeScanRoots(body.scanRoots);
if (!roots) { if (!roots) {
@@ -91,6 +59,11 @@ export function registerSettingsRoutes(
} }
setSetting(db, SCAN_INTERVAL_KEY, String(interval)); 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()); return reply.send(snapshot());
}); });
} }

View File

@@ -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 daudit', () => {
it('journalise la création de token et lexpose 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);
});
});

View File

@@ -19,7 +19,7 @@
ClaudeCode'llbeabletoread,edit,andexecutefileshere. ClaudeCode'llbeabletoread,edit,andexecutefileshere.
]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; ]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;;
@@ -321,13 +321,13 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
Simrin(2s · thinking with xhigh effort) Simrin(2s · thinking with xhigh effort)
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
thinking with xhigh effort thinking with xhigh effort
@@ -343,7 +343,7 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
thinking with xhigh effort thinking with xhigh effort
@@ -359,7 +359,7 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
57thinking with xhigh effort 57thinking with xhigh effort
@@ -566,7 +566,7 @@
esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore esctointerruptYou'veused96%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
nn nn

View File

@@ -19,7 +19,7 @@
ClaudeCode'llbeabletoread,edit,andexecutefileshere. ClaudeCode'llbeabletoread,edit,andexecutefileshere.
]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; ]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;;
@@ -475,7 +475,7 @@
4thought for 1s) 4thought for 1s)

View File

@@ -19,7 +19,7 @@
ClaudeCode'llbeabletoread,edit,andexecutefileshere. ClaudeCode'llbeabletoread,edit,andexecutefileshere.
]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; ]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;;

View File

@@ -19,7 +19,7 @@
ClaudeCode'llbeabletoread,edit,andexecutefileshere. ClaudeCode'llbeabletoread,edit,andexecutefileshere.
]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; ]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;;
@@ -218,13 +218,13 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
✻ ✻
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
✽ ✽
@@ -242,7 +242,7 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
(1s · thinking with xhigh effort) (1s · thinking with xhigh effort)
@@ -251,7 +251,7 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
on2thinking with xhigh effort on2thinking with xhigh effort
@@ -416,7 +416,7 @@
   
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore esctointerruptYou'veused91%ofyoursessionlimit·resets7pm(America/Lima)·/usage-creditstorequestmore
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
@@ -430,16 +430,16 @@
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
*ng *ng
[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l
… …

View File

@@ -19,7 +19,7 @@
ClaudeCode'llbeabletoread,edit,andexecutefileshere. ClaudeCode'llbeabletoread,edit,andexecutefileshere.
]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;; ]8;id=zaxmda;https://code.claude.com/docs/en/securitySecurity guide]8;;

View File

@@ -13,7 +13,7 @@ function writeJsonl(projectsDir: string, cwd: string, sid: string, lines: object
describe('munge', () => { describe('munge', () => {
it('reproduit le nom de dossier ~/.claude/projects', () => { it('reproduit le nom de dossier ~/.claude/projects', () => {
expect(munge('/home/x/My Project!')).toBe('-home-x-My-Project-'); 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');
}); });
}); });

View File

@@ -38,6 +38,16 @@ describe('scanForRepos', () => {
expect(paths).toEqual([join(root, 'a')]); 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 () => { it('ignore node_modules et les dotdirs', async () => {
const root = tmpRoot(); const root = tmpRoot();
makeRepo(root, 'node_modules', 'pkg'); makeRepo(root, 'node_modules', 'pkg');

View File

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

View File

@@ -1,5 +1,5 @@
// Routes Réglages : GET expose la config non sensible (jamais les secrets), PATCH n'écrit que // 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 { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -65,7 +65,7 @@ afterAll(async () => {
}); });
describe('GET /api/v1/settings', () => { 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() }); const res = await bundle.app.inject({ method: 'GET', url: '/api/v1/settings', headers: auth() });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse; 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.bind).toBe('127.0.0.1');
expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']); expect(body.server.allowedOrigins).toEqual(['https://host.tailnet.ts.net']);
expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer expect(body.server.vapidPublicKey).toBeTruthy(); // clé publique = sûre à exposer
expect(body.settings.giteaUrl).toBeNull(); // défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
// défauts de découverte : home (1 racine) + intervalle 5 min expect(body.settings.scanRoots).toEqual([]);
expect(Array.isArray(body.settings.scanRoots)).toBe(true);
expect(body.settings.scanRoots).toHaveLength(1);
expect(body.settings.scanIntervalMin).toBe(5); expect(body.settings.scanIntervalMin).toBe(5);
}); });
@@ -93,29 +91,7 @@ describe('GET /api/v1/settings', () => {
}); });
}); });
describe('PATCH /api/v1/settings', () => { describe('PATCH /api/v1/settings — sécurité', () => {
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 lURL 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);
});
it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => { it('ignore toute clé hors allow-list (ne touche pas aux secrets)', async () => {
const before = getSetting(db, 'server_secret'); 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' } }); 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 () => { 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); expect(res.statusCode).toBe(401);
}); });
}); });

View File

@@ -199,8 +199,7 @@ export interface ServerInfo {
export interface SettingsResponse { export interface SettingsResponse {
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */ /** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
settings: { settings: {
giteaUrl: string | null; /** racines absolues scannées pour la découverte auto des repos (défaut : aucune → pas de scan). */
/** racines absolues scannées pour la découverte auto des repos (défaut : home). */
scanRoots: string[]; scanRoots: string[];
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */ /** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
scanIntervalMin: number; scanIntervalMin: number;
@@ -208,10 +207,48 @@ export interface SettingsResponse {
server: ServerInfo; server: ServerInfo;
} }
export interface UpdateSettingsRequest { 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. */ /** racines absolues à scanner (chemins normalisés, ≤ 16) ; remplace la liste. */
scanRoots?: string[]; scanRoots?: string[];
/** intervalle du re-scan périodique en minutes (01440 ; 0 désactive). */ /** intervalle du re-scan périodique en minutes (01440 ; 0 désactive). */
scanIntervalMin?: number; 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 };
}

View File

@@ -3,8 +3,8 @@ import { useRoute, type RouteLocationRaw } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Boxes, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue'; import { Boxes, GitBranch, LifeBuoy, Settings, TerminalSquare } from '@lucide/vue';
import { useSessionsStore } from '../stores/sessions'; import { useSessionsStore } from '../stores/sessions';
import { useSettingsStore } from '../stores/settings';
import GiteaIcon from '../components/ui/GiteaIcon.vue'; import GiteaIcon from '../components/ui/GiteaIcon.vue';
import { REPO_SOURCE_URL } from '../lib/constants';
export interface NavEntry { export interface NavEntry {
key: string; key: string;
@@ -23,7 +23,6 @@ export function useNav() {
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
const sessions = useSessionsStore(); const sessions = useSessionsStore();
const settings = useSettingsStore();
// sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile). // sessions vivantes bloquées sur un dialogue (le cœur de la supervision mobile).
const waitingCount = computed( 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 }, { 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). // Onglets secondaires : réglages, aide, et lien externe « Code source » (en dur, toujours présent).
const secondary = computed<NavEntry[]>(() => { const secondary = computed<NavEntry[]>(() => [
const items: NavEntry[] = [ { key: 'settings', to: { name: 'settings' }, icon: Settings, label: t('nav.settings'), match: ['settings'], badge: 0 },
{ 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: '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 },
]; ]);
if (settings.giteaUrl) {
items.push({ key: 'gitea', href: settings.giteaUrl, icon: GiteaIcon, label: t('nav.gitea'), match: [], badge: 0 });
}
return items;
});
const isActive = (match: string[]): boolean => match.includes(String(route.name)); const isActive = (match: string[]): boolean => match.includes(String(route.name));

View File

@@ -181,7 +181,7 @@ export default {
settings: 'Settings', settings: 'Settings',
help: 'Help', help: 'Help',
more: 'More', more: 'More',
gitea: 'Open Gitea', gitea: 'Source code',
waiting: 'waiting', waiting: 'waiting',
}, },
controls: { controls: {
@@ -333,14 +333,16 @@ export default {
confirmRevoke: 'Confirm revoke', confirmRevoke: 'Confirm revoke',
tokenRevoked: 'Token revoked', tokenRevoked: 'Token revoked',
lastTokenError: 'You cannot revoke the last active token — create another one first.', 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', save: 'Save',
saved: 'Saved', 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 // Découverte des dépôts
discovery: 'Repository discovery', discovery: 'Repository discovery',
discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.', discoveryHint: 'Arboretum scans these folders for git repositories and registers them automatically. Hidden repos are kept and never re-added.',

View File

@@ -184,7 +184,7 @@ const fr: typeof en = {
settings: 'Réglages', settings: 'Réglages',
help: 'Aide', help: 'Aide',
more: 'Plus', more: 'Plus',
gitea: 'Ouvrir Gitea', gitea: 'Code source',
waiting: 'en attente', waiting: 'en attente',
}, },
controls: { controls: {
@@ -336,14 +336,16 @@ const fr: typeof en = {
confirmRevoke: 'Confirmer la révocation', confirmRevoke: 'Confirmer la révocation',
tokenRevoked: 'Jeton révoqué', tokenRevoked: 'Jeton révoqué',
lastTokenError: 'Impossible de révoquer le dernier jeton actif — créez-en un autre dabord.', lastTokenError: 'Impossible de révoquer le dernier jeton actif — créez-en un autre dabord.',
// Intégrations
integrations: 'Intégrations',
gitea: 'Gitea',
giteaUrlLabel: 'URL de linstance Gitea',
giteaUrlPlaceholder: 'https://git.exemple.com',
giteaUrlHint: 'Ajoute une icône de raccourci dans la navigation. Laissez vide pour la masquer.',
save: 'Enregistrer', save: 'Enregistrer',
saved: 'Enregistré', 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 daudit des opérations sensibles.',
exportData: 'Exporter mes données',
refreshAudit: 'Journal daudit',
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 // Découverte des dépôts
discovery: '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.', 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.',

View File

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

View File

@@ -3,11 +3,10 @@ import { ref } from 'vue';
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared'; import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
import { api } from '../lib/api'; 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. // Les préférences purement client (langue) restent gérées par l'i18n/localStorage.
export const useSettingsStore = defineStore('settings', () => { export const useSettingsStore = defineStore('settings', () => {
const server = ref<ServerInfo | null>(null); const server = ref<ServerInfo | null>(null);
const giteaUrl = ref<string | null>(null);
const scanRoots = ref<string[]>([]); const scanRoots = ref<string[]>([]);
const scanIntervalMin = ref(0); const scanIntervalMin = ref(0);
const loaded = ref(false); const loaded = ref(false);
@@ -15,7 +14,6 @@ export const useSettingsStore = defineStore('settings', () => {
function apply(res: SettingsResponse): void { function apply(res: SettingsResponse): void {
server.value = res.server; server.value = res.server;
giteaUrl.value = res.settings.giteaUrl;
scanRoots.value = res.settings.scanRoots; scanRoots.value = res.settings.scanRoots;
scanIntervalMin.value = res.settings.scanIntervalMin; scanIntervalMin.value = res.settings.scanIntervalMin;
loaded.value = true; 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 };
}); });

View File

@@ -102,25 +102,6 @@
</ul> </ul>
</section> </section>
<!-- Intégrations : Gitea -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Puzzle :size="16" /> {{ t('settings.integrations') }}
</h2>
<form class="flex flex-col gap-2" @submit.prevent="saveGitea">
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.giteaUrlLabel') }}</span>
<div class="flex flex-wrap items-center gap-2">
<input v-model="giteaInput" type="url" class="input min-w-48 flex-1" :placeholder="t('settings.giteaUrlPlaceholder')" />
<BaseButton type="submit" variant="primary" :loading="settings.saving" :disabled="!giteaDirty">
{{ t('settings.save') }}
</BaseButton>
</div>
</label>
<p class="text-xs text-zinc-500">{{ t('settings.giteaUrlHint') }}</p>
</form>
</section>
<!-- Découverte des dépôts --> <!-- Découverte des dépôts -->
<section class="card flex flex-col gap-3"> <section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100"> <h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -156,6 +137,26 @@
</div> </div>
</section> </section>
<!-- Sécurité & conformité -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Shield :size="16" /> {{ t('settings.compliance') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.complianceHint') }}</p>
<div class="flex flex-wrap gap-2">
<BaseButton size="sm" :icon="Download" :loading="exporting" @click="exportData">{{ t('settings.exportData') }}</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="RefreshCw" :loading="auditing" @click="loadAudit">{{ t('settings.refreshAudit') }}</BaseButton>
<BaseButton variant="ghost" size="sm" :icon="Trash2" @click="deleteMyData">{{ t('settings.deleteData') }}</BaseButton>
</div>
<ul v-if="audit.length" class="flex flex-col divide-y divide-zinc-800/80 text-xs">
<li v-for="e in audit" :key="e.id" class="flex items-center justify-between gap-3 py-1.5">
<span class="font-mono text-zinc-300">{{ e.action }}</span>
<span class="truncate text-zinc-500">{{ e.actor }}</span>
<span class="shrink-0 text-zinc-600">{{ new Date(e.ts).toLocaleString() }}</span>
</li>
</ul>
</section>
<!-- Serveur (lecture seule) --> <!-- Serveur (lecture seule) -->
<section class="card flex flex-col gap-3"> <section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100"> <h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -183,8 +184,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Bell, BellOff, Check, Copy, FolderOpen, KeyRound, Plus, Puzzle, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue'; import { Bell, BellOff, Check, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
import type { CreateTokenResponse, TokenInfo, TokensListResponse } from '@arboretum/shared'; import type {
AuditLogEntry,
AuditLogsResponse,
CreateTokenResponse,
DataExportResponse,
DeleteMyDataResponse,
TokenInfo,
TokensListResponse,
} from '@arboretum/shared';
import { api, ApiError } from '../lib/api'; import { api, ApiError } from '../lib/api';
import { useSettingsStore } from '../stores/settings'; import { useSettingsStore } from '../stores/settings';
import { usePushStore } from '../stores/push'; import { usePushStore } from '../stores/push';
@@ -272,19 +281,6 @@ async function copy(text: string, target?: 'new'): Promise<void> {
} }
} }
// ---- Gitea ----
const giteaInput = ref('');
const giteaDirty = computed(() => giteaInput.value.trim() !== (settings.giteaUrl ?? ''));
async function saveGitea(): Promise<void> {
try {
await settings.save({ giteaUrl: giteaInput.value.trim() || null });
giteaInput.value = settings.giteaUrl ?? '';
toasts.success(t('settings.saved'));
} catch (e) {
toasts.error(e);
}
}
// ---- Découverte des dépôts ---- // ---- Découverte des dépôts ----
const rootsDraft = ref<string[]>([]); const rootsDraft = ref<string[]>([]);
const intervalDraft = ref(0); const intervalDraft = ref(0);
@@ -313,9 +309,55 @@ async function saveDiscovery(): Promise<void> {
} }
} }
// ---- Sécurité & conformité (audit / RGPD) ----
const audit = ref<AuditLogEntry[]>([]);
const auditing = ref(false);
const exporting = ref(false);
async function loadAudit(): Promise<void> {
auditing.value = true;
try {
audit.value = (await api.get<AuditLogsResponse>('/api/v1/audit-logs?limit=20')).entries;
} catch (e) {
toasts.error(e);
} finally {
auditing.value = false;
}
}
async function exportData(): Promise<void> {
exporting.value = true;
try {
const data = await api.get<DataExportResponse>('/api/v1/data/export');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'arboretum-data-export.json';
a.click();
URL.revokeObjectURL(url);
} catch (e) {
toasts.error(e);
} finally {
exporting.value = false;
}
}
async function deleteMyData(): Promise<void> {
if (!window.confirm(t('settings.deleteConfirm'))) return;
try {
const pending = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', {});
if (pending.status !== 'pending' || !pending.confirm) return;
const done = await api.post<DeleteMyDataResponse>('/api/v1/data/delete-my-data', { confirm: pending.confirm });
toasts.success(t('settings.deleteDone'));
if (done.summary.tokenRevoked) window.location.reload(); // token courant révoqué → re-login
} catch (e) {
toasts.error(e);
}
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]); await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
giteaInput.value = settings.giteaUrl ?? '';
syncDiscoveryDraft(); syncDiscoveryDraft();
void push.refresh(); void push.refresh();
}); });

View File

@@ -174,8 +174,8 @@ const en: HelpSection[] = [
blurb: 'Shortcuts to the tools around your repos.', blurb: 'Shortcuts to the tools around your repos.',
items: [ items: [
{ {
title: 'Gitea', title: 'Source code',
body: 'Set your Gitea instance URL in Settings → Integrations to add a one-click Gitea icon to the navigation. Leave it empty to hide the icon.', body: 'The “Source code” icon in the navigation always links to the Arboretum source repository on Gitea.',
}, },
], ],
}, },
@@ -342,8 +342,8 @@ const fr: HelpSection[] = [
blurb: 'Des raccourcis vers les outils autour de vos dépôts.', blurb: 'Des raccourcis vers les outils autour de vos dépôts.',
items: [ items: [
{ {
title: 'Gitea', title: 'Code source',
body: 'Renseignez lURL de votre instance Gitea dans Réglages → Intégrations pour ajouter une icône Gitea en un clic dans la navigation. Laissez vide pour masquer licône.', body: 'Licône « Code source » de la navigation pointe toujours vers le dépôt source dArboretum sur Gitea.',
}, },
], ],
}, },

View File

@@ -1,6 +1,6 @@
# Spike S4 — Découverte JSONL + registre — VERDICT : ✅ GO # Spike S4 — Découverte JSONL + registre — VERDICT : ✅ GO
Exécuté le 2026-06-11 sur la machine de Johan (CLI 2.1.170/2.1.173 actifs, transcripts 2.1.139 → 2.1.173). Script : `scan.mjs`, résultats bruts : `result.json`. Exécuté sur un poste de dev (CLI 2.1.170/2.1.173 actifs, transcripts 2.1.139 → 2.1.173). Script : `scan.mjs`.
## Critères Go ## Critères Go

View File

@@ -1,141 +0,0 @@
{
"scan": {
"files": 1318,
"bytes": 470368918,
"ok": 1316,
"noCwd": 2,
"noSessionId": 0,
"empty": 0,
"errors": 0,
"mb": "449",
"scanMs": "1368",
"okPct": "99.8 %"
},
"mungeValidation": {
"mungeMatch": 1316,
"mungeMismatch": 0,
"mismatchSamples": []
},
"lineTypes": {
"user": 1315,
"last-prompt": 1313,
"attachment": 1308,
"assistant": 1204,
"queue-operation": 1001,
"ai-title": 470,
"file-history-snapshot": 431,
"system": 398,
"permission-mode": 387,
"agent-name": 228,
"mode": 219,
"bridge-session": 6
},
"versions": {
"2.1.139": 16,
"2.1.140": 86,
"2.1.142": 19,
"2.1.143": 113,
"2.1.144": 58,
"2.1.145": 81,
"2.1.146": 43,
"2.1.150": 137,
"2.1.152": 59,
"2.1.153": 18,
"2.1.156": 80,
"2.1.159": 77,
"2.1.160": 58,
"2.1.161": 102,
"2.1.162": 60,
"2.1.163": 3,
"2.1.165": 20,
"2.1.166": 6,
"2.1.168": 83,
"2.1.169": 74,
"2.1.170": 90,
"2.1.173": 33
},
"registry": {
"count": 3,
"entries": [
{
"file": "1061541.json",
"pid": 1061541,
"sessionId": "026cc229-c1cd-4327-9c53-68406794e870",
"cwd": "/home/johan/WebstormProjects",
"status": "busy",
"waitingFor": null,
"kind": "interactive",
"version": "2.1.170",
"peerProtocol": 1,
"updatedAt": 1781192047718,
"alive": true,
"procStartOk": true
},
{
"file": "1701999.json",
"pid": 1701999,
"sessionId": "8733906c-832a-4e74-aab7-7d8b2f5eb9d2",
"cwd": "/home/johan/WebstormProjects",
"status": "idle",
"waitingFor": null,
"kind": "interactive",
"version": "2.1.173",
"peerProtocol": 1,
"updatedAt": 1781191339305,
"alive": true,
"procStartOk": true
},
{
"file": "597768.json",
"pid": 597768,
"sessionId": "95f8eaf3-feeb-4fbe-8848-fa7a994c8d7f",
"cwd": "/home/johan/WebstormProjects",
"status": "busy",
"waitingFor": null,
"kind": "interactive",
"version": "2.1.170",
"peerProtocol": 1,
"updatedAt": 1781192263383,
"alive": true,
"procStartOk": true
}
]
},
"attachment": {
"inWebstormProjects": 573,
"outside": 743,
"topDirs": {
"(racine)": 323,
"beehelp_prospect": 133,
"techos_web": 31,
"beehelp_api": 27,
"techos_api": 23,
"beehelp_web": 15,
"teknoroot-simulator-web": 5,
"beehelp_claude_marketplace": 3,
"beehelp_lambdas": 2,
"teknoroot-simulator-api": 2,
"Beehelp_iac": 1,
"Techos_V1_outdated": 1,
"bee-tiptap-resolver": 1,
"beehelp_api-wt-qualiobee-docs": 1,
"beehelp_api-wt": 1
}
},
"failuresSample": [
{
"fp": "/home/johan/.claude/projects/-home-johan-WebstormProjects-beehelp-api/0811ca37-c488-45aa-8a9e-5ecd6c87dfeb.jsonl",
"why": "pas de cwd",
"types": [
"queue-operation"
]
},
{
"fp": "/home/johan/.claude/projects/-tmp/59e9a9f8-a5f2-4773-b1f6-72fa8b9514a9.jsonl",
"why": "pas de cwd",
"types": [
"queue-operation"
]
}
]
}