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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import Fastify, { type FastifyError, type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
@@ -14,6 +14,7 @@ import { WorktreeManager } from './core/worktree-manager.js';
|
||||
import { RepoDiscoveryService } from './core/repo-discovery.js';
|
||||
import { GroupManager } from './core/group-manager.js';
|
||||
import { PushService } from './core/push-service.js';
|
||||
import { loadSecretBox } from './core/secret-box.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerRepoRoutes } from './routes/repos.js';
|
||||
@@ -22,7 +23,37 @@ import { registerWorktreeRoutes } from './routes/worktrees.js';
|
||||
import { registerPushRoutes } from './routes/push.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerFsRoutes } from './routes/fs.js';
|
||||
import { registerAuditRoutes } from './routes/audit.js';
|
||||
import { registerDataRoutes } from './routes/data.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
import { isHttpsRequest } from './routes/auth.js';
|
||||
|
||||
// En-têtes de sécurité posés sur TOUTES les réponses (defense-in-depth + conformité scanners
|
||||
// entreprise). Le terminal web reste du RCE par conception : ces en-têtes durcissent la SPA et
|
||||
// le transport, ils ne remplacent pas le modèle loopback + auth + Origin.
|
||||
const SECURITY_HEADERS: Record<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' {
|
||||
interface FastifyRequest {
|
||||
@@ -46,9 +77,11 @@ export interface AppBundle {
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||
const auth = new AuthService(db);
|
||||
// Chiffrement au repos des secrets (server_secret, clé privée VAPID) dans la base.
|
||||
const box = loadSecretBox(config.dataDir);
|
||||
const auth = new AuthService(db, box);
|
||||
const limiter = new LoginRateLimiter();
|
||||
const push = new PushService(db, config.vapidContact);
|
||||
const push = new PushService(db, config.vapidContact, undefined, box);
|
||||
const manager = new PtyManager(db, config.claudeSessionsDir, push);
|
||||
const discovery = new DiscoveryService({
|
||||
ptyManager: manager,
|
||||
@@ -60,6 +93,43 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
const repoDiscovery = new RepoDiscoveryService(db, worktrees);
|
||||
const groups = new GroupManager(db);
|
||||
|
||||
// En-têtes de sécurité sur toute réponse + no-store sur les réponses sensibles (API/WS).
|
||||
// onSend DOIT retourner le payload (sinon Fastify vide la réponse).
|
||||
app.addHook('onSend', async (req, reply, payload) => {
|
||||
reply.removeHeader('Server'); // anti-fingerprinting (no-op si absent)
|
||||
for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v);
|
||||
if (isHttpsRequest(req)) {
|
||||
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
// Garde CSRF defense-in-depth : une mutation porteuse d'un corps DOIT être en application/json
|
||||
// (bloque les soumissions de formulaire cross-site en text/plain, que Fastify parserait sinon).
|
||||
app.addHook('preHandler', async (req, reply) => {
|
||||
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)) return;
|
||||
const len = req.headers['content-length'];
|
||||
if (!len || len === '0') return; // pas de corps : rien à valider
|
||||
const ct = (req.headers['content-type'] ?? '').toLowerCase();
|
||||
if (!ct.startsWith('application/json')) {
|
||||
return reply.status(415).send({ error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: 'Content-Type must be application/json' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Handler d'erreur : ne jamais fuiter de stack/chemin au client ; détail complet côté logs.
|
||||
app.setErrorHandler((err: FastifyError, req, reply) => {
|
||||
const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 500;
|
||||
if (status >= 500) {
|
||||
req.log.error({ err }, 'unhandled error');
|
||||
return reply.status(status).send({ error: { code: 'INTERNAL', message: 'Internal Server Error' } });
|
||||
}
|
||||
// erreurs client (4xx) : code générique, message court non sensible.
|
||||
return reply.status(status).send({ error: { code: err.code ?? 'BAD_REQUEST', message: err.message } });
|
||||
});
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
options: { maxPayload: 1024 * 1024 },
|
||||
@@ -97,14 +167,16 @@ export function buildApp(config: Config, db: Db, serverVersion: string): AppBund
|
||||
}
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion, db);
|
||||
registerSessionRoutes(app, manager, discovery);
|
||||
registerRepoRoutes(app, worktrees, db);
|
||||
registerGroupRoutes(app, groups);
|
||||
registerGroupRoutes(app, groups, db);
|
||||
registerWorktreeRoutes(app, worktrees);
|
||||
registerPushRoutes(app, push);
|
||||
registerPushRoutes(app, push, db);
|
||||
registerSettingsRoutes(app, db, config, serverVersion, push);
|
||||
registerFsRoutes(app);
|
||||
registerAuditRoutes(app, db);
|
||||
registerDataRoutes(app, db, auth);
|
||||
// La route websocket doit être déclarée APRÈS le chargement du plugin (contexte
|
||||
// encapsulé) — sinon le handler reçoit la signature REST (request, reply).
|
||||
void app.register(async (scoped) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
import type { SecretBox } from '../core/secret-box.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
|
||||
const COOKIE_NAME = 'arb_session';
|
||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||
@@ -12,11 +14,21 @@ export interface AuthContext {
|
||||
export class AuthService {
|
||||
private readonly secret: Buffer;
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
let secretHex = getSetting(db, 'server_secret');
|
||||
if (!secretHex) {
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
box?: SecretBox,
|
||||
) {
|
||||
// server_secret chiffré au repos quand un SecretBox est fourni (prod). Migration douce :
|
||||
// une valeur en clair pré-existante est re-chiffrée à la lecture.
|
||||
const stored = getSetting(db, 'server_secret');
|
||||
let secretHex: string;
|
||||
if (!stored) {
|
||||
secretHex = randomBytes(32).toString('hex');
|
||||
setSetting(db, 'server_secret', secretHex);
|
||||
setSetting(db, 'server_secret', box ? box.encrypt(secretHex) : secretHex);
|
||||
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'server_secret' });
|
||||
} else {
|
||||
secretHex = box ? box.decrypt(stored) : stored;
|
||||
if (box && !box.isEncrypted(stored)) setSetting(db, 'server_secret', box.encrypt(secretHex));
|
||||
}
|
||||
this.secret = Buffer.from(secretHex, 'hex');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { parseArgs } from 'node:util';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { chmodSync, mkdirSync } from 'node:fs';
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
@@ -53,6 +53,14 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
|
||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
// La DB contient des secrets (server_secret, clé privée VAPID, hashs de tokens) : le dossier de
|
||||
// données ne doit jamais être lisible par d'autres utilisateurs du système. chmod best-effort
|
||||
// (peut échouer sur certains FS Windows/montés ; le démarrage avertit alors sans bloquer).
|
||||
try {
|
||||
chmodSync(dataDir, 0o700);
|
||||
} catch {
|
||||
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
|
||||
}
|
||||
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
|
||||
return {
|
||||
port: Number(values.port),
|
||||
|
||||
65
packages/server/src/core/audit-log.ts
Normal file
65
packages/server/src/core/audit-log.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
import type { SecretBox } from './secret-box.js';
|
||||
import { recordAudit } from './audit-log.js';
|
||||
|
||||
// web-push est publié en CommonJS : on le charge via require (verbatimModuleSyntax + NodeNext),
|
||||
// typé par l'import type — même pattern que @xterm/headless dans screen-reader.ts.
|
||||
@@ -47,16 +49,22 @@ export class PushService {
|
||||
private readonly db: Db,
|
||||
private readonly contact: string = 'mailto:arboretum@localhost',
|
||||
sender?: PushSender,
|
||||
box?: SecretBox,
|
||||
) {
|
||||
this.send = sender ?? ((sub, payload, options) => webpush.sendNotification(sub, payload, options as Parameters<typeof webpush.sendNotification>[2]));
|
||||
let pub = getSetting(db, 'vapid_public');
|
||||
let priv = getSetting(db, 'vapid_private');
|
||||
let pub = getSetting(db, 'vapid_public'); // clé publique : jamais chiffrée (sûre à exposer)
|
||||
const storedPriv = getSetting(db, 'vapid_private');
|
||||
let priv = storedPriv ? (box ? box.decrypt(storedPriv) : storedPriv) : null;
|
||||
if (!pub || !priv) {
|
||||
const keys = webpush.generateVAPIDKeys();
|
||||
pub = keys.publicKey;
|
||||
priv = keys.privateKey;
|
||||
setSetting(db, 'vapid_public', pub);
|
||||
setSetting(db, 'vapid_private', priv);
|
||||
setSetting(db, 'vapid_private', box ? box.encrypt(priv) : priv);
|
||||
recordAudit(db, { actor: 'system', action: 'secret.generate', resourceId: 'vapid' });
|
||||
} else if (box && storedPriv && !box.isEncrypted(storedPriv)) {
|
||||
// migration douce : clé privée pré-existante en clair → re-chiffrée.
|
||||
setSetting(db, 'vapid_private', box.encrypt(priv));
|
||||
}
|
||||
this.vapidPublic = pub;
|
||||
this.vapidPrivate = priv;
|
||||
|
||||
@@ -37,7 +37,9 @@ interface Frame {
|
||||
* Parcours itératif (pile explicite, jamais de récursion non bornée) des `roots`.
|
||||
* Règles :
|
||||
* - un dossier contenant `.git` (fichier OU dossier → couvre les worktrees liés) est un repo :
|
||||
* on l'enregistre et on NE descend PAS dedans (sous-modules/worktrees imbriqués ignorés) ;
|
||||
* on l'enregistre ; en profondeur on NE descend PAS dedans (sous-modules/worktrees imbriqués
|
||||
* ignorés). EXCEPTION : une racine fournie (depth 0) qui est elle-même un repo est aussi un
|
||||
* conteneur — on l'enregistre ET on continue de descendre pour trouver les repos internes ;
|
||||
* - on n'empile que les vrais sous-dossiers (`d.isDirectory()`), donc les symlinks ne sont PAS
|
||||
* suivis (anti-cycle + anti-sortie de racine), et on saute dotdirs + excludeDirs ;
|
||||
* - bornes : `maxDepth`, `maxRepos`, et un éventuel `signal` (timeout global) ;
|
||||
@@ -68,10 +70,13 @@ export async function scanForRepos(
|
||||
if (seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
|
||||
// un repo : on l'enregistre et on ne descend pas.
|
||||
// Un dossier avec `.git` est un repo. En PROFONDEUR (depth > 0) c'est une feuille : on
|
||||
// l'enregistre sans descendre (on n'ouvre pas les sous-modules/worktrees imbriqués). Mais une
|
||||
// RACINE fournie explicitement (depth 0) est un CONTENEUR de scan : si elle est elle-même un
|
||||
// repo on l'enregistre, puis on CONTINUE de descendre pour découvrir les dépôts qu'elle contient.
|
||||
if (existsSync(join(dir, '.git'))) {
|
||||
found.add(dir);
|
||||
continue;
|
||||
if (depth > 0) continue;
|
||||
}
|
||||
if (depth >= limits.maxDepth) continue;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Réglages de la découverte auto des repos, persistés dans la table `settings` (clé/valeur).
|
||||
// Frontière de sécurité : ces clés sont NON sensibles et n'entrent dans l'allow-list du PATCH
|
||||
// /api/v1/settings que via les validateurs ci-dessous. Aucun secret ne transite par ici.
|
||||
import { homedir } from 'node:os';
|
||||
import { getSetting } from '../db/index.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { isSafeAbsolutePath } from './git.js';
|
||||
@@ -15,12 +14,14 @@ export const DEFAULT_SCAN_INTERVAL_MIN = 5;
|
||||
export const MAX_SCAN_INTERVAL_MIN = 1440;
|
||||
export const MAX_SCAN_ROOTS = 16;
|
||||
|
||||
/** Racines à scanner. Défaut : le home de l'utilisateur. Lecture tolérante (JSON malformé → défaut). */
|
||||
/**
|
||||
* Racines à scanner. Défaut : AUCUNE racine → aucun scan (clean install).
|
||||
* L'utilisateur ajoute ses racines via Réglages → Découverte. Lecture tolérante (JSON malformé → []).
|
||||
*/
|
||||
export function readScanRoots(db: Db): string[] {
|
||||
const raw = getSetting(db, SCAN_ROOTS_KEY);
|
||||
if (!raw) return [homedir()];
|
||||
const parsed = normalizeScanRoots(safeParse(raw));
|
||||
return parsed && parsed.length > 0 ? parsed : [homedir()];
|
||||
if (!raw) return [];
|
||||
return normalizeScanRoots(safeParse(raw)) ?? [];
|
||||
}
|
||||
|
||||
/** Intervalle périodique en minutes (0 = désactivé). Défaut DEFAULT_SCAN_INTERVAL_MIN. */
|
||||
|
||||
69
packages/server/src/core/secret-box.ts
Normal file
69
packages/server/src/core/secret-box.ts
Normal 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'));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { chmodSync, existsSync } from 'node:fs';
|
||||
|
||||
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
{
|
||||
@@ -99,6 +100,24 @@ const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
id: 6,
|
||||
sql: `ALTER TABLE repos ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;`,
|
||||
},
|
||||
{
|
||||
// Journal d'audit (conformité entreprise : GDPR/SOX/ISO 27001). Trace les mutations
|
||||
// sensibles (tokens, réglages, secrets, abonnements push, groupes). Ne contient JAMAIS
|
||||
// de secret en clair — `details` est un JSON de métadonnées non sensibles.
|
||||
id: 7,
|
||||
sql: `
|
||||
CREATE TABLE audit_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
ts TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
details TEXT,
|
||||
result TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_audit_ts ON audit_logs(ts);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
@@ -107,10 +126,27 @@ export function openDb(path: string): Db {
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
hardenDbPermissions(path);
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restreint la base (et ses fichiers WAL/SHM) à 0o600 — proprio uniquement. La base contient des
|
||||
* secrets (server_secret, clé privée VAPID, hashs de tokens) : elle ne doit jamais être lisible par
|
||||
* d'autres utilisateurs du système. Best-effort : ignoré sur les FS sans permissions POSIX.
|
||||
*/
|
||||
function hardenDbPermissions(path: string): void {
|
||||
if (path === ':memory:') return;
|
||||
for (const p of [path, `${path}-wal`, `${path}-shm`]) {
|
||||
try {
|
||||
if (existsSync(p)) chmodSync(p, 0o600);
|
||||
} catch {
|
||||
/* FS sans permissions POSIX (Windows / montage) : ignoré */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(db: DatabaseSync): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||
const applied = new Set(
|
||||
|
||||
17
packages/server/src/routes/audit.ts
Normal file
17
packages/server/src/routes/audit.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import type {
|
||||
TokensListResponse,
|
||||
} from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
|
||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||
function isHttpsRequest(req: FastifyRequest): boolean {
|
||||
export function isHttpsRequest(req: FastifyRequest): boolean {
|
||||
const xfp = req.headers['x-forwarded-proto'];
|
||||
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||
return proto === 'https';
|
||||
@@ -23,6 +25,7 @@ export function registerAuthRoutes(
|
||||
auth: AuthService,
|
||||
limiter: LoginRateLimiter,
|
||||
serverVersion: string,
|
||||
db: Db,
|
||||
): void {
|
||||
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
||||
const wait = limiter.check();
|
||||
@@ -33,9 +36,11 @@ export function registerAuthRoutes(
|
||||
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
||||
if (!ctx) {
|
||||
limiter.recordFailure();
|
||||
recordAudit(db, { actor: 'anonymous', action: 'login.failure', result: 'denied' });
|
||||
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
||||
}
|
||||
limiter.recordSuccess();
|
||||
recordAudit(db, { actor: ctx.tokenId, action: 'login.success' });
|
||||
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
@@ -78,18 +83,22 @@ export function registerAuthRoutes(
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'label must be 1–64 characters' } });
|
||||
}
|
||||
const { id, token } = auth.createTokenRecord(label);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'token.create', resourceId: id, details: { label } });
|
||||
return reply.status(201).send({ id, label, token } satisfies CreateTokenResponse);
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/tokens/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const result = auth.revokeToken(id);
|
||||
const actor = req.authContext?.tokenId ?? 'unknown';
|
||||
if (result === 'not_found') {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No active token with this id' } });
|
||||
}
|
||||
if (result === 'last') {
|
||||
recordAudit(db, { actor, action: 'token.revoke', resourceId: id, result: 'denied', details: { reason: 'last_active_token' } });
|
||||
return reply.status(409).send({ error: { code: 'LAST_TOKEN', message: 'Cannot revoke the last active token' } });
|
||||
}
|
||||
recordAudit(db, { actor, action: 'token.revoke', resourceId: id });
|
||||
// 200 + corps JSON (pas 204) : le mini-client REST du front parse toujours la réponse.
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
54
packages/server/src/routes/data.ts
Normal file
54
packages/server/src/routes/data.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import type {
|
||||
UpdateGroupRequest,
|
||||
} from '@arboretum/shared';
|
||||
import type { GroupManager } from '../core/group-manager.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import { sendManagerError } from './repos.js';
|
||||
|
||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): void {
|
||||
export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager, db: Db): void {
|
||||
app.get('/api/v1/groups', async (): Promise<GroupsListResponse> => ({ groups: gm.listGroups() }));
|
||||
|
||||
app.get('/api/v1/groups/:id', async (req, reply) => {
|
||||
@@ -33,6 +35,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
||||
...(body.color !== undefined ? { color: body.color } : {}),
|
||||
...(Array.isArray(body.repoIds) ? { repoIds: body.repoIds } : {}),
|
||||
});
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.create', resourceId: group.id, details: { label: group.label } });
|
||||
return reply.status(201).send({ group } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
@@ -48,6 +51,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
||||
...(body.description !== undefined ? { description: body.description } : {}),
|
||||
...(body.color !== undefined ? { color: body.color } : {}),
|
||||
});
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.update', resourceId: id });
|
||||
return reply.send({ group } satisfies GroupResponse);
|
||||
} catch (err) {
|
||||
return sendManagerError(reply, err);
|
||||
@@ -59,6 +63,7 @@ export function registerGroupRoutes(app: FastifyInstance, gm: GroupManager): voi
|
||||
if (!gm.deleteGroup(id)) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No group with this id' } });
|
||||
}
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'group.delete', resourceId: id });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { PushSubscribeRequest, PushUnsubscribeRequest, VapidKeyResponse } from '@arboretum/shared';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
|
||||
export function registerPushRoutes(app: FastifyInstance, push: PushService): void {
|
||||
export function registerPushRoutes(app: FastifyInstance, push: PushService, db: Db): void {
|
||||
app.get('/api/v1/push/vapid-public-key', async (): Promise<VapidKeyResponse> => ({ key: push.publicKey() }));
|
||||
|
||||
app.post('/api/v1/push/subscribe', async (req, reply) => {
|
||||
@@ -15,6 +17,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi
|
||||
// garanti non-null : la route est protégée par le preValidation global.
|
||||
const tokenId = req.authContext!.tokenId;
|
||||
push.subscribe(tokenId, { endpoint: body.endpoint, keys: { p256dh: body.keys.p256dh, auth: body.keys.auth } }, req.headers['user-agent'] ?? null);
|
||||
recordAudit(db, { actor: tokenId, action: 'push.subscribe' });
|
||||
return reply.status(201).send({ ok: true });
|
||||
});
|
||||
|
||||
@@ -24,6 +27,7 @@ export function registerPushRoutes(app: FastifyInstance, push: PushService): voi
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'endpoint is required' } });
|
||||
}
|
||||
push.unsubscribe(body.endpoint);
|
||||
recordAudit(db, { actor: req.authContext?.tokenId ?? 'unknown', action: 'push.unsubscribe' });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { ServerInfo, SettingsResponse, UpdateSettingsRequest } from '@arboretum/shared';
|
||||
import type { Config } from '../config.js';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
import { type Db, setSetting } from '../db/index.js';
|
||||
import type { PushService } from '../core/push-service.js';
|
||||
import { recordAudit } from '../core/audit-log.js';
|
||||
import {
|
||||
SCAN_INTERVAL_KEY,
|
||||
SCAN_ROOTS_KEY,
|
||||
@@ -15,23 +16,7 @@ import {
|
||||
readScanRoots,
|
||||
} from '../core/scan-settings.js';
|
||||
|
||||
// Clés de `settings` modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||
const GITEA_URL_KEY = 'gitea_url';
|
||||
|
||||
/** Valide/normalise une URL Gitea : http(s) uniquement (anti-XSS sur le href de l'icône). */
|
||||
function normalizeGiteaUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
|
||||
export function registerSettingsRoutes(
|
||||
app: FastifyInstance,
|
||||
db: Db,
|
||||
@@ -39,8 +24,6 @@ export function registerSettingsRoutes(
|
||||
serverVersion: string,
|
||||
push: PushService,
|
||||
): void {
|
||||
// '' (effacé) est normalisé en null côté réponse.
|
||||
const readGiteaUrl = (): string | null => getSetting(db, GITEA_URL_KEY) || null;
|
||||
const serverInfo = (): ServerInfo => ({
|
||||
version: serverVersion,
|
||||
port: config.port,
|
||||
@@ -52,7 +35,6 @@ export function registerSettingsRoutes(
|
||||
});
|
||||
const snapshot = (): SettingsResponse => ({
|
||||
settings: {
|
||||
giteaUrl: readGiteaUrl(),
|
||||
scanRoots: readScanRoots(db),
|
||||
scanIntervalMin: readScanIntervalMin(db),
|
||||
},
|
||||
@@ -63,20 +45,6 @@ export function registerSettingsRoutes(
|
||||
|
||||
app.patch('/api/v1/settings', async (req, reply) => {
|
||||
const body = (req.body as Partial<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) {
|
||||
const roots = normalizeScanRoots(body.scanRoots);
|
||||
if (!roots) {
|
||||
@@ -91,6 +59,11 @@ export function registerSettingsRoutes(
|
||||
}
|
||||
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
|
||||
}
|
||||
recordAudit(db, {
|
||||
actor: req.authContext?.tokenId ?? 'unknown',
|
||||
action: 'settings.update',
|
||||
details: { keys: Object.keys(body) },
|
||||
});
|
||||
return reply.send(snapshot());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user