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:
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'));
|
||||
}
|
||||
Reference in New Issue
Block a user