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:
2026-06-18 16:44:25 +02:00
parent 755cba6b78
commit 33295a7aaf
41 changed files with 973 additions and 338 deletions

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 { 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;

View File

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

View File

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

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'));
}