fix(security): protect wiki review and administrator bootstrap

This commit is contained in:
David 2026-09-22 09:10:49 +02:00
parent ef0d7d5f67
commit 7fd6eeccae
34 changed files with 879 additions and 148 deletions

View File

@ -7,6 +7,9 @@ const applicationRoot = existsSync(path.join(__dirname, 'dist'))
const { databaseTlsOptions } = require( const { databaseTlsOptions } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls') path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls')
); );
const { SafeDatabaseLogger } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/safe-database-logger')
);
const AppDataSource = new DataSource({ const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
@ -26,6 +29,8 @@ const AppDataSource = new DataSource({
], ],
synchronize: false, synchronize: false,
logging: true, logging: true,
logger: new SafeDatabaseLogger(true),
migrationsTransactionMode: 'all',
}); });
console.log('🚀 Starting Xpeditis Backend Migration Script...'); console.log('🚀 Starting Xpeditis Backend Migration Script...');
@ -53,6 +58,6 @@ AppDataSource.initialize()
}) })
.catch(error => { .catch(error => {
console.error('❌ Error during migration:'); console.error('❌ Error during migration:');
console.error(error); console.error('Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
}); });

View File

@ -12,6 +12,9 @@ const applicationRoot = existsSync(path.join(__dirname, 'dist'))
const { databaseTlsOptions } = require( const { databaseTlsOptions } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls') path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/database-tls')
); );
const { SafeDatabaseLogger } = require(
path.join(applicationRoot, 'dist/infrastructure/persistence/typeorm/safe-database-logger')
);
async function waitForPostgres(maxAttempts = 30) { async function waitForPostgres(maxAttempts = 30) {
console.log('⏳ Waiting for PostgreSQL to be ready...'); console.log('⏳ Waiting for PostgreSQL to be ready...');
@ -66,6 +69,8 @@ async function runMigrations() {
], ],
synchronize: false, synchronize: false,
logging: true, logging: true,
logger: new SafeDatabaseLogger(true),
migrationsTransactionMode: 'all',
}); });
try { try {
@ -87,7 +92,7 @@ async function runMigrations() {
console.log('✅ Database migrations completed'); console.log('✅ Database migrations completed');
return true; return true;
} catch (error) { } catch (error) {
console.error('❌ Error during migration:', error); console.error('❌ Migration failed. Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
} }
} }
@ -119,7 +124,7 @@ async function main() {
if (require.main === module) { if (require.main === module) {
main().catch(error => { main().catch(error => {
console.error('❌ Startup failed:', error); console.error('❌ Startup failed. Check migration prerequisites and database availability.');
process.exit(1); process.exit(1);
}); });
} }

View File

@ -94,7 +94,7 @@ describe('knowledgeCapabilities', () => {
id: 'w1', id: 'w1',
locale: 'fr', locale: 'fr',
...page, ...page,
authorUserId: 'someone', authorUserId: 'user',
authorOrganizationId: 'org', authorOrganizationId: 'org',
}); });
contributions.findByTitle.mockResolvedValue(existing); contributions.findByTitle.mockResolvedValue(existing);
@ -111,26 +111,17 @@ describe('knowledgeCapabilities', () => {
expect(result).toMatchObject({ status: 'pending_review' }); expect(result).toMatchObject({ status: 'pending_review' });
}); });
it('sends a revised page back through review', async () => { it('does not withdraw a published page even for its original author', async () => {
const published = WikiContribution.create({ const published = WikiContribution.create({
id: 'w1', id: 'w1',
locale: 'fr', locale: 'fr',
...page, ...page,
authorUserId: 'someone', authorUserId: actor.id,
authorOrganizationId: 'org', authorOrganizationId: actor.organizationId,
}).publish('admin'); }).publish('admin');
contributions.findByTitle.mockResolvedValue(published); contributions.findByTitle.mockResolvedValue(published);
await expect(invoke(contribute(), page)).rejects.toThrow(CapabilityInputError);
await invoke(contribute(), { expect(contributions.save).not.toHaveBeenCalled();
...page,
body: `${BODY} Le manifeste est déposé par le transitaire.`,
});
// Sans cela, la validation d'un administrateur porterait sur un texte que
// l'assistant a remplace depuis.
const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.PENDING);
expect(saved.reviewedByUserId).toBeUndefined();
}); });
it('refuses content that recommends FCL, with a message the assistant can relay', async () => { it('refuses content that recommends FCL, with a message the assistant can relay', async () => {

View File

@ -1,9 +1,13 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port'; import { TradeRetrievalPort } from '@domain/ports/out/trade-assistant.port';
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import {
WikiContributionConflict,
WikiContributionRepository,
} from '@domain/ports/out/wiki-contribution.repository';
import { import {
WikiContribution, WikiContribution,
WikiContributionRejected, WikiContributionRejected,
WikiContributionStatus,
} from '@domain/entities/wiki-contribution.entity'; } from '@domain/entities/wiki-contribution.entity';
import { import {
WIKI_REFUSAL_MESSAGES, WIKI_REFUSAL_MESSAGES,
@ -105,7 +109,7 @@ function contributeWikiPage(
return { return {
policy: { name: 'contribute_wiki_page', scope: 'write' }, policy: { name: 'contribute_wiki_page', scope: 'write' },
description: description:
"Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour la proposition existante si le titre est déjà pris.", "Propose au wiki Xpeditis une page d'information générale sur le transport international, quand la documentation ne couvre pas le sujet. La page part en relecture : elle n'est publiée qu'après validation par un administrateur. Réservé au savoir durable et valable pour tous les clients : jamais un cas client, un dossier, un tarif, un contenu recommandant le FCL, ni un sujet de transport national. Met à jour uniquement votre propre proposition non publiée si le titre est déjà pris.",
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -152,6 +156,17 @@ function contributeWikiPage(
const existing = await contributions.findByTitle(locale, topic, title); const existing = await contributions.findByTitle(locale, topic, title);
if (
existing &&
(existing.authorUserId !== actor.id ||
existing.authorOrganizationId !== actor.organizationId ||
existing.status === WikiContributionStatus.PUBLISHED)
) {
throw new CapabilityInputError(
'Cette page ne peut pas être modifiée par cette contribution.'
);
}
// Le doublon n'est teste que pour une page nouvelle : reviser un // Le doublon n'est teste que pour une page nouvelle : reviser un
// complement existant se heurterait sinon a ce complement lui-meme. // complement existant se heurterait sinon a ce complement lui-meme.
if (!existing) { if (!existing) {
@ -178,7 +193,17 @@ function contributeWikiPage(
}) })
); );
const saved = await contributions.save(page); let saved: WikiContribution;
try {
saved = await contributions.save(page, actor);
} catch (error) {
if (error instanceof WikiContributionConflict) {
throw new CapabilityInputError(
'La proposition a changé ou ce titre est déjà utilisé. Relisez la page avant de réessayer.'
);
}
throw error;
}
return { return {
// Le resultat dit l'etat reel, pas l'intention : le modele annonce une // Le resultat dit l'etat reel, pas l'intention : le modele annonce une
// proposition en attente, jamais une page publiee. // proposition en attente, jamais une page publiee.

View File

@ -10,7 +10,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsOptional, IsString, Length } from 'class-validator'; import { IsInt, Min, IsOptional, IsString, Length } from 'class-validator';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator'; import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator'; import { Roles } from '../decorators/roles.decorator';
@ -21,6 +21,10 @@ import { WikiReviewService, WikiReviewer } from './wiki-review.service';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value); const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
export class PublishWikiContributionDto { export class PublishWikiContributionDto {
@IsInt()
@Min(1)
expectedVersion: number;
/** Correction de l'intitulé de section. Absent : celui proposé est conservé. */ /** Correction de l'intitulé de section. Absent : celui proposé est conservé. */
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@ -37,6 +41,10 @@ export class PublishWikiContributionDto {
} }
export class RejectWikiContributionDto { export class RejectWikiContributionDto {
@IsInt()
@Min(1)
expectedVersion: number;
/** Motif, conservé avec la page pour relire les refus. */ /** Motif, conservé avec la page pour relire les refus. */
@IsOptional() @IsOptional()
@Transform(trim) @Transform(trim)
@ -85,7 +93,7 @@ export class WikiReviewController {
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectWikiContributionDto @Body() dto: RejectWikiContributionDto
) { ) {
return this.service.reject(reviewerOf(user), id, dto.note); return this.service.reject(reviewerOf(user), id, dto.expectedVersion, dto.note);
} }
} }

View File

@ -0,0 +1,52 @@
import { ConflictException } from '@nestjs/common';
import { WikiContribution, WikiContributionProps } from '@domain/entities/wiki-contribution.entity';
import { knowledgeCapabilities } from '../mcp/capabilities/knowledge.capabilities';
import { WikiReviewService } from './wiki-review.service';
const body =
'Le manifeste de cargaison décrit les marchandises transportées par voie maritime internationale. Le transitaire le transmet aux autorités douanières avant le chargement au port de départ. Les informations doivent être vérifiées pour éviter une immobilisation des marchandises au terminal.';
const draft = () =>
WikiContribution.create({
id: 'page',
locale: 'fr',
topic: 'douanes',
title: 'Le manifeste maritime',
section: 'Transmission du manifeste',
body,
authorUserId: 'author',
authorOrganizationId: 'org',
});
const actor = { id: 'intruder', organizationId: 'other-org', role: 'USER' };
const reviewer = { id: 'admin', organizationId: 'admin-org', email: 'admin@example.test' };
it.each(['pending', 'published'])(
'refuses a foreign %s contribution without saving',
async status => {
const page = status === 'published' ? draft().publish('admin') : draft();
const save = jest.fn(async value => value);
const repo = { findByTitle: jest.fn(async () => page), save };
const cap = knowledgeCapabilities({ search: jest.fn(async () => []) }, repo as never).find(
c => c.policy.name === 'contribute_wiki_page'
)!;
await expect(cap.handler({ ...page.toObject(), language: 'fr' }, actor)).rejects.toThrow();
expect(save).not.toHaveBeenCalled();
}
);
it('refuses publication when the content changed after the review list was loaded', async () => {
const changed = WikiContribution.fromPersistence({
...draft().toObject(),
body: body + ' Le texte a changé.',
version: 2,
} as WikiContributionProps);
const save = jest.fn(async value => value);
const service = new WikiReviewService(
{ findById: jest.fn(async () => changed), save } as never,
{} as never,
{ log: jest.fn() } as never
);
await expect(
service.publish(reviewer, 'page', { expectedVersion: 1 } as never)
).rejects.toBeInstanceOf(ConflictException);
expect(save).not.toHaveBeenCalled();
});

View File

@ -15,15 +15,18 @@ const reviewer = { id: 'admin-1', email: 'admin@xpeditis.com', organizationId: '
const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`; const BODY = `La règle du 24 heures impose de transmettre le manifeste de cargaison aux douanes du pays de destination avant le chargement du navire au port d'embarquement. Elle s'applique au transport maritime international et conditionne l'autorisation de charger. Un dépôt tardif expose l'expéditeur à un refus d'embarquement et à une immobilisation du conteneur au terminal.`;
const proposal = () => const proposal = () =>
WikiContribution.create({ WikiContribution.fromPersistence({
id: 'w1', ...WikiContribution.create({
locale: 'fr', id: 'w1',
topic: 'douanes', locale: 'fr',
title: 'La règle des 24 heures', topic: 'douanes',
section: 'Dépôt du manifeste', title: 'La règle des 24 heures',
body: BODY, section: 'Dépôt du manifeste',
authorUserId: 'user', body: BODY,
authorOrganizationId: 'org', authorUserId: 'user',
authorOrganizationId: 'org',
}).toObject(),
version: 1,
}); });
describe('WikiReviewService', () => { describe('WikiReviewService', () => {
@ -87,7 +90,7 @@ describe('WikiReviewService', () => {
/* ---------------------------------------------------------------------- */ /* ---------------------------------------------------------------------- */
it('publishes the page and links to it', async () => { it('publishes the page and links to it', async () => {
const view = await service.publish(reviewer, 'w1'); const view = await service.publish(reviewer, 'w1', { expectedVersion: 1 });
const saved: WikiContribution = contributions.save.mock.calls[0][0]; const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.PUBLISHED); expect(saved.status).toBe(WikiContributionStatus.PUBLISHED);
@ -98,7 +101,7 @@ describe('WikiReviewService', () => {
it('keeps the reviewer edits instead of the proposed text', async () => { it('keeps the reviewer edits instead of the proposed text', async () => {
const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`; const corrected = `${BODY} Le dépôt incombe au transitaire, jamais au destinataire.`;
await service.publish(reviewer, 'w1', { body: corrected }); await service.publish(reviewer, 'w1', { expectedVersion: 1, body: corrected });
expect(contributions.save.mock.calls[0][0].body).toBe(corrected); expect(contributions.save.mock.calls[0][0].body).toBe(corrected);
}); });
@ -106,14 +109,14 @@ describe('WikiReviewService', () => {
it('refuses an edit that breaks the content policy', async () => { it('refuses an edit that breaks the content policy', async () => {
const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`; const advocacy = `${BODY} Au-delà de 15 m³, nous recommandons le FCL.`;
await expect(service.publish(reviewer, 'w1', { body: advocacy })).rejects.toThrow( await expect(
WikiContributionRejected service.publish(reviewer, 'w1', { expectedVersion: 1, body: advocacy })
); ).rejects.toThrow(WikiContributionRejected);
expect(contributions.save).not.toHaveBeenCalled(); expect(contributions.save).not.toHaveBeenCalled();
}); });
it('keeps a rejected page, with its reason', async () => { it('keeps a rejected page, with its reason', async () => {
const view = await service.reject(reviewer, 'w1', ' Source non vérifiée '); const view = await service.reject(reviewer, 'w1', 1, ' Source non vérifiée ');
const saved: WikiContribution = contributions.save.mock.calls[0][0]; const saved: WikiContribution = contributions.save.mock.calls[0][0];
expect(saved.status).toBe(WikiContributionStatus.REJECTED); expect(saved.status).toBe(WikiContributionStatus.REJECTED);
@ -123,7 +126,7 @@ describe('WikiReviewService', () => {
}); });
it('records who decided what', async () => { it('records who decided what', async () => {
await service.publish(reviewer, 'w1'); await service.publish(reviewer, 'w1', { expectedVersion: 1 });
expect(audit.log).toHaveBeenCalledWith( expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -138,6 +141,8 @@ describe('WikiReviewService', () => {
it('reports an unknown page rather than failing silently', async () => { it('reports an unknown page rather than failing silently', async () => {
contributions.findById.mockResolvedValue(null); contributions.findById.mockResolvedValue(null);
await expect(service.publish(reviewer, 'w1')).rejects.toThrow(NotFoundException); await expect(service.publish(reviewer, 'w1', { expectedVersion: 1 })).rejects.toThrow(
NotFoundException
);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { import {
WikiContribution, WikiContribution,
WikiContributionStatus, WikiContributionStatus,
@ -6,6 +6,7 @@ import {
import { import {
WIKI_CONTRIBUTION_REPOSITORY, WIKI_CONTRIBUTION_REPOSITORY,
WikiContributionRepository, WikiContributionRepository,
WikiContributionConflict,
} from '@domain/ports/out/wiki-contribution.repository'; } from '@domain/ports/out/wiki-contribution.repository';
import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity'; import { AuditAction, AuditStatus } from '@domain/entities/audit-log.entity';
import { USER_REPOSITORY, UserRepository } from '@domain/ports/out/user.repository'; import { USER_REPOSITORY, UserRepository } from '@domain/ports/out/user.repository';
@ -55,9 +56,14 @@ export class WikiReviewService {
}; };
} }
async publish(reviewer: WikiReviewer, id: string, edits?: { section?: string; body?: string }) { async publish(
reviewer: WikiReviewer,
id: string,
edits: { expectedVersion: number; section?: string; body?: string }
) {
const page = await this.find(id); const page = await this.find(id);
const published = await this.contributions.save(page.publish(reviewer.id, edits)); this.assertVersion(page, edits.expectedVersion);
const published = await this.saveReview(page.publish(reviewer.id, edits));
await this.record(reviewer, published, AuditStatus.SUCCESS, 'published'); await this.record(reviewer, published, AuditStatus.SUCCESS, 'published');
this.logger.log(`Wiki contribution "${published.title}" published by ${reviewer.email}`); this.logger.log(`Wiki contribution "${published.title}" published by ${reviewer.email}`);
@ -65,15 +71,41 @@ export class WikiReviewService {
return this.toView(published, { [reviewer.id]: reviewer.email }); return this.toView(published, { [reviewer.id]: reviewer.email });
} }
async reject(reviewer: WikiReviewer, id: string, note?: string) { async reject(reviewer: WikiReviewer, id: string, expectedVersion: number, note?: string) {
const page = await this.find(id); const page = await this.find(id);
const rejected = await this.contributions.save(page.reject(reviewer.id, note)); this.assertVersion(page, expectedVersion);
const rejected = await this.saveReview(page.reject(reviewer.id, note));
await this.record(reviewer, rejected, AuditStatus.SUCCESS, 'rejected'); await this.record(reviewer, rejected, AuditStatus.SUCCESS, 'rejected');
return this.toView(rejected, { [reviewer.id]: reviewer.email }); return this.toView(rejected, { [reviewer.id]: reviewer.email });
} }
private assertVersion(page: WikiContribution, expectedVersion: number): void {
if (
!Number.isSafeInteger(expectedVersion) ||
expectedVersion < 1 ||
page.version !== expectedVersion
) {
throw new ConflictException(
'La proposition a changé. Rechargez-la et relisez-la avant de décider.'
);
}
}
private async saveReview(page: WikiContribution): Promise<WikiContribution> {
try {
return await this.contributions.save(page);
} catch (error) {
if (error instanceof WikiContributionConflict) {
throw new ConflictException(
'La proposition a changé. Rechargez-la et relisez-la avant de décider.'
);
}
throw error;
}
}
private async find(id: string): Promise<WikiContribution> { private async find(id: string): Promise<WikiContribution> {
const page = await this.contributions.findById(id); const page = await this.contributions.findById(id);
if (!page) throw new NotFoundException('Contribution introuvable.'); if (!page) throw new NotFoundException('Contribution introuvable.');
@ -130,6 +162,7 @@ export class WikiReviewService {
private toView(page: WikiContribution, reviewers: Record<string, string>) { private toView(page: WikiContribution, reviewers: Record<string, string>) {
return { return {
id: page.id, id: page.id,
version: page.version,
locale: page.locale, locale: page.locale,
topic: page.topic, topic: page.topic,
title: page.title, title: page.title,

View File

@ -32,6 +32,7 @@ export enum WikiContributionStatus {
export interface WikiContributionProps { export interface WikiContributionProps {
id: string; id: string;
version: number;
locale: string; locale: string;
/** Sujet du wiki auquel la page se rattache, ex. `douanes`. */ /** Sujet du wiki auquel la page se rattache, ex. `douanes`. */
topic: string; topic: string;
@ -65,7 +66,9 @@ export class WikiContribution {
* qu'il l'explique a l'utilisateur au lieu de reessayer. * qu'il l'explique a l'utilisateur au lieu de reessayer.
*/ */
static create( static create(
props: Omit<WikiContributionProps, 'id' | 'status' | 'createdAt' | 'updatedAt'> & { id: string } props: Omit<WikiContributionProps, 'id' | 'status' | 'createdAt' | 'updatedAt' | 'version'> & {
id: string;
}
): WikiContribution { ): WikiContribution {
const draft: WikiContributionDraft = { const draft: WikiContributionDraft = {
topic: props.topic, topic: props.topic,
@ -82,6 +85,7 @@ export class WikiContribution {
...props, ...props,
// Le statut n'est pas un parametre : rien ne nait publie, pas meme une // Le statut n'est pas un parametre : rien ne nait publie, pas meme une
// page ecrite par un administrateur. // page ecrite par un administrateur.
version: 0,
status: WikiContributionStatus.PENDING, status: WikiContributionStatus.PENDING,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -172,6 +176,10 @@ export class WikiContribution {
}); });
} }
get version(): number {
return this.props.version;
}
get status(): WikiContributionStatus { get status(): WikiContributionStatus {
return this.props.status; return this.props.status;
} }

View File

@ -1,5 +1,12 @@
import { WikiContribution, WikiContributionStatus } from '../../entities/wiki-contribution.entity'; import { WikiContribution, WikiContributionStatus } from '../../entities/wiki-contribution.entity';
export class WikiContributionConflict extends Error {}
export interface WikiContributionWriter {
id: string;
organizationId: string;
}
export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository'; export const WIKI_CONTRIBUTION_REPOSITORY = 'WikiContributionRepository';
export interface WikiContributionRepository { export interface WikiContributionRepository {
@ -31,7 +38,11 @@ export interface WikiContributionRepository {
*/ */
findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>; findByTitle(locale: string, topic: string, title: string): Promise<WikiContribution | null>;
save(contribution: WikiContribution): Promise<WikiContribution>; /** Inserts version 0; otherwise atomically updates the exact version. Contributor writes also enforce ownership and unpublished state. */
save(
contribution: WikiContribution,
contributor?: WikiContributionWriter
): Promise<WikiContribution>;
/** /**
* Empreinte du jeu **publie**, qui change des qu'une page est validee, * Empreinte du jeu **publie**, qui change des qu'une page est validee,

View File

@ -190,6 +190,7 @@ describe('WikiRetriever', () => {
describe('contributions', () => { describe('contributions', () => {
const page = WikiContribution.fromPersistence({ const page = WikiContribution.fromPersistence({
id: 'w1', id: 'w1',
version: 1,
locale: 'fr', locale: 'fr',
topic: 'vgm', topic: 'vgm',
title: 'VGM et pesée', title: 'VGM et pesée',

View File

@ -68,14 +68,14 @@ describe('decideAdminBootstrap', () => {
}); });
}); });
it('promeut et reactive un compte existant, sans hash', () => { it('refuse de promouvoir un compte non administrateur existant', () => {
const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), { const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), {
resetPassword: false, resetPassword: false,
}); });
expect(decision).toEqual({ expect(decision).toEqual({
create: false, create: false,
promote: true, promote: false,
activate: true, activate: false,
applyPassword: false, applyPassword: false,
}); });
}); });

View File

@ -15,10 +15,11 @@
* *
* CE QUE FAIT CE SERVICE * CE QUE FAIT CE SERVICE
* ---------------------- * ----------------------
* Si BOOTSTRAP_ADMIN_EMAIL est renseigné, le compte est garanti ADMIN et actif, * Si BOOTSTRAP_ADMIN_EMAIL est renseigné, un compte absent est créé ADMIN.
* et créé s'il n'existe pas. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué : * Un compte existant non administrateur n'est jamais promu automatiquement.
* Seul un administrateur existant peut être réactivé. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué :
* - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ; * - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ;
* - ou à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer * - ou à un administrateur si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer
* ensuite : tant qu'il reste, un mot de passe changé depuis l'interface * ensuite : tant qu'il reste, un mot de passe changé depuis l'interface
* serait remplacé au lancement suivant). * serait remplacé au lancement suivant).
* En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est * En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est
@ -34,6 +35,7 @@ import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { isProductionDeployment } from './deployment-environment';
/** Paramètres Argon2id du projet (cf. auth.service.ts). */ /** Paramètres Argon2id du projet (cf. auth.service.ts). */
const ARGON2_OPTIONS = { const ARGON2_OPTIONS = {
@ -89,6 +91,11 @@ export function decideAdminBootstrap(
return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash }; return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash };
} }
// An address is not proof that a self-registered account belongs to the operator.
if (account.role !== 'ADMIN') {
return { create: false, promote: false, activate: false, applyPassword: false };
}
const hashAlreadyApplied = account.passwordHash === config.passwordHash; const hashAlreadyApplied = account.passwordHash === config.passwordHash;
const applyPassword = const applyPassword =
!!config.passwordHash && !!config.passwordHash &&
@ -97,7 +104,7 @@ export function decideAdminBootstrap(
return { return {
create: false, create: false,
promote: account.role !== 'ADMIN', promote: false,
activate: !account.isActive, activate: !account.isActive,
applyPassword, applyPassword,
}; };
@ -173,6 +180,13 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
); );
if (config) { if (config) {
if (
config.passwordHash &&
isProductionDeployment() &&
(await argon2.verify(config.passwordHash, 'Password123!'))
) {
throw new Error('The public demonstration password cannot be used for an administrator.');
}
await this.dataSource.transaction(manager => this.applyConfig(manager, config)); await this.dataSource.transaction(manager => this.applyConfig(manager, config));
} }
@ -192,7 +206,7 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
last_login_at: Date | null; last_login_at: Date | null;
password_hash: string; password_hash: string;
}> = await manager.query( }> = await manager.query(
`SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1`, `SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1 FOR UPDATE`,
[config.email] [config.email]
); );
@ -206,6 +220,12 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
} }
: null; : null;
if (account && account.role !== 'ADMIN') {
this.logger.error(
'[amorçage admin] Adresse déjà occupée par un compte non administrateur : aucune promotion automatique.'
);
return;
}
const decision = decideAdminBootstrap(account, config); const decision = decideAdminBootstrap(account, config);
if (decision.create) { if (decision.create) {
@ -225,11 +245,10 @@ export class AdminBootstrapService implements OnApplicationBootstrap {
await manager.query( await manager.query(
`UPDATE "users" `UPDATE "users"
SET "role" = 'ADMIN', SET "is_active" = true,
"is_active" = true,
"password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END, "password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END,
"updated_at" = NOW() "updated_at" = NOW()
WHERE "id" = $1`, WHERE "id" = $1 AND "role" = 'ADMIN'`,
[account!.id, decision.applyPassword, config.passwordHash ?? ''] [account!.id, decision.applyPassword, config.passwordHash ?? '']
); );

View File

@ -0,0 +1,45 @@
import { decideAdminBootstrap } from './admin-bootstrap.service';
import { isProductionDeployment } from './deployment-environment';
it.each([false, true])('does not promote an email claimant, reset=%s', resetPassword => {
const result = decideAdminBootstrap(
{
id: 'claimant',
role: 'USER',
isActive: true,
passwordHash: 'attacker-hash',
lastLoginAt: new Date(),
},
{ passwordHash: 'operator-hash', resetPassword }
);
expect(result).toEqual({ create: false, promote: false, activate: false, applyPassword: false });
});
it('protects a production runtime deployed as preprod from demo credentials', () => {
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: 'preprod' })).toBe(true);
});
import * as argon2 from 'argon2';
import { GuardPendingLegacyAdminBootstrap1755999999999 } from './migrations/1755999999999-GuardPendingLegacyAdminBootstrap';
it('rejects a public bootstrap password at any configured address before the legacy insertion', async () => {
const keys = ['NODE_ENV', 'BOOTSTRAP_ADMIN_EMAIL', 'BOOTSTRAP_ADMIN_PASSWORD_HASH'];
const old = keys.map(key => process.env[key]);
process.env.NODE_ENV = 'production';
process.env.BOOTSTRAP_ADMIN_EMAIL = 'operator@example.test';
process.env.BOOTSTRAP_ADMIN_PASSWORD_HASH = await argon2.hash('Password123!');
const query = jest.fn().mockResolvedValue([]);
try {
await expect(
new GuardPendingLegacyAdminBootstrap1755999999999().up({
query,
isTransactionActive: true,
} as never)
).rejects.toThrow('public demonstration password');
expect(query).toHaveBeenCalledTimes(1);
} finally {
keys.forEach((key, i) => {
if (old[i] === undefined) delete process.env[key];
else process.env[key] = old[i];
});
}
});

View File

@ -23,6 +23,7 @@ export const AppDataSource = new DataSource({
entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')], entities: [join(__dirname, 'entities', '*.orm-entity.{ts,js}')],
migrations: [join(__dirname, 'migrations', '*.{ts,js}')], migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
subscribers: [], subscribers: [],
migrationsTransactionMode: 'all',
synchronize: false, // Never use in production synchronize: false, // Never use in production
logging: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development',
logger: new SafeDatabaseLogger(process.env.NODE_ENV === 'development'), logger: new SafeDatabaseLogger(process.env.NODE_ENV === 'development'),

View File

@ -1,6 +1,7 @@
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { resolve, join } from 'path'; import { resolve, join } from 'path';
import { runInNewContext } from 'vm'; import { runInNewContext } from 'vm';
import { SafeDatabaseLogger } from './safe-database-logger';
import { databaseTlsOptions } from './database-tls'; import { databaseTlsOptions } from './database-tls';
describe('database bootstrap TLS wiring', () => { describe('database bootstrap TLS wiring', () => {
@ -42,6 +43,10 @@ describe('database bootstrap TLS wiring', () => {
if (name === 'path') return { join, resolve }; if (name === 'path') return { join, resolve };
if (name === 'fs') return { existsSync: () => packaged }; if (name === 'fs') return { existsSync: () => packaged };
if (name === 'child_process') return { spawn: jest.fn() }; if (name === 'child_process') return { spawn: jest.fn() };
if (
name === `${applicationRoot}/dist/infrastructure/persistence/typeorm/safe-database-logger`
)
return { SafeDatabaseLogger };
helperPaths.push(name); helperPaths.push(name);
expect(name).toBe( expect(name).toBe(
`${applicationRoot}/dist/infrastructure/persistence/typeorm/database-tls` `${applicationRoot}/dist/infrastructure/persistence/typeorm/database-tls`
@ -79,6 +84,8 @@ describe('database bootstrap TLS wiring', () => {
}); });
} }
for (const config of sources) { for (const config of sources) {
expect(config.logger).toBeInstanceOf(SafeDatabaseLogger);
expect(config.migrationsTransactionMode).toBe('all');
expect(config.migrations).toEqual([ expect(config.migrations).toEqual([
`${applicationRoot}/dist/infrastructure/persistence/typeorm/migrations/*.js`, `${applicationRoot}/dist/infrastructure/persistence/typeorm/migrations/*.js`,
]); ]);

View File

@ -1,19 +1,20 @@
import { isProductionDeployment } from './deployment-environment'; import { isProductionDeployment } from './deployment-environment';
describe('isProductionDeployment', () => { describe('deployment credential protection', () => {
it('traite la preprod comme non productive meme avec NODE_ENV=production', () => { it.each(['preprod', 'production', 'prod', 'staging', 'preview', '', 'unknown'])(
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: 'preprod' })).toBe(false); 'protects %s',
}); APP_ENV => {
expect(isProductionDeployment({ APP_ENV })).toBe(true);
it('reconnait APP_ENV=production, quelle que soit la casse', () => { }
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: ' Production ' })).toBe(true); );
expect(isProductionDeployment({ APP_ENV: 'prod' })).toBe(true); it.each(['dev', 'development', 'local', 'test'])(
}); 'allows explicit isolated %s only outside production runtime',
APP_ENV => {
it('retombe sur NODE_ENV quand APP_ENV est absent ou vide', () => { expect(isProductionDeployment({ NODE_ENV: 'test', APP_ENV })).toBe(false);
expect(isProductionDeployment({ NODE_ENV: 'production' })).toBe(true); expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV })).toBe(true);
expect(isProductionDeployment({ NODE_ENV: 'production', APP_ENV: '' })).toBe(true); }
expect(isProductionDeployment({ NODE_ENV: 'development' })).toBe(false); );
expect(isProductionDeployment({})).toBe(false); it('fails closed for unspecified environment', () => {
expect(isProductionDeployment({})).toBe(true);
}); });
}); });

View File

@ -1,24 +1,21 @@
/** /**
* Environnement de déploiement, tel que le voient les migrations. * Security classification used by legacy seed migrations.
* Public deployments, unknown environments and production runtimes are protected.
* Demo credentials are allowed only in explicitly local development/test runtimes.
* *
* NODE_ENV ne suffit pas à le déterminer : c'est un réglage d'exécution Node * NODE_ENV=production always wins over APP_ENV: preproduction runs a production
* (optimisations, cookies Secure…) et la preprod le positionne légitimement à * Node runtime and is exposed through the same application login boundary.
* "production". S'y fier seul a fait neutraliser les comptes de démonstration * An empty or misspelled deployment name must not enable known passwords.
* de la preprod, alors qu'ils devaient y rester utilisables.
* *
* APP_ENV dit OÙ l'on déploie (development, preprod, production). Il prime dès * This compatibility helper is imported by already-applied migration classes;
* qu'il est renseigné. En son absence, on retombe sur NODE_ENV : une production * their source remains unchanged. A separate forward migration also rotates
* qui n'aurait pas encore défini APP_ENV reste ainsi protégée. * exposed credentials on databases where the older neutralization already ran.
* * A protected environment can still bootstrap an administrator using an
* Fichier volontairement hors du dossier migrations/ : TypeORM chargerait sinon * operator-provided, non-demonstration password hash or email recovery.
* tout module de ce dossier comme une migration.
*/ */
export function isProductionDeployment(env: NodeJS.ProcessEnv = process.env): boolean { export function isProductionDeployment(env: NodeJS.ProcessEnv = process.env): boolean {
const nodeEnv = (env.NODE_ENV ?? '').trim().toLowerCase();
const appEnv = (env.APP_ENV ?? '').trim().toLowerCase(); const appEnv = (env.APP_ENV ?? '').trim().toLowerCase();
if (nodeEnv === 'production') return true;
if (appEnv) { return !['development', 'dev', 'local', 'test'].includes(appEnv || nodeEnv);
return appEnv === 'production' || appEnv === 'prod';
}
return env.NODE_ENV === 'production';
} }

View File

@ -0,0 +1,38 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as argon2 from 'argon2';
import { isProductionDeployment } from '../deployment-environment';
/** Deliberately ordered BEFORE the immutable legacy bootstrap migration.
* Both supported runners use the default all-migrations transaction. Refuse an
* unsafe upgrade instead of letting the old migration promote an email claimant.
*/
export class GuardPendingLegacyAdminBootstrap1755999999999 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
const applied: unknown[] = await queryRunner.query('SELECT 1 FROM migrations WHERE name=$1', [
'BootstrapAdminFromEnv1756000000001',
]);
if (applied.length) return;
const email = (process.env.BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase();
if (!email) return;
const hash = (process.env.BOOTSTRAP_ADMIN_PASSWORD_HASH ?? '').trim();
if (hash && isProductionDeployment() && (await argon2.verify(hash, 'Password123!'))) {
throw new Error('The public demonstration password cannot be used for bootstrap.');
}
if (!queryRunner.isTransactionActive) {
throw new Error('Bootstrap safety requires migration transaction mode all.');
}
await queryRunner.query('LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE');
const rows: Array<{ role: string }> = await queryRunner.query(
'SELECT role FROM users WHERE email=$1',
[email]
);
if (rows.some(row => row.role !== 'ADMIN')) {
throw new Error(
'Bootstrap email belongs to an existing non-administrator. Choose an unused bootstrap address before migrating.'
);
}
}
async down(): Promise<void> {
/* No persistent data changed. */
}
}

View File

@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class VersionWikiContributions1790000000003 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE wiki_contributions ADD COLUMN version integer NOT NULL DEFAULT 1 CHECK (version > 0)'
);
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE wiki_contributions DROP COLUMN version');
}
}

View File

@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
import { isProductionDeployment } from '../deployment-environment';
/** Forward-only cleanup for databases whose older neutralization already ran. */
export class RotatePublicDemoCredentials1790000000004 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
if (!isProductionDeployment()) return;
const rows: Array<{ id: string; password_hash: string }> = await queryRunner.query(
'SELECT id, password_hash FROM users WHERE email = ANY($1) FOR UPDATE',
[
[
'admin@xpeditis.com',
'manager@xpeditis.com',
'user@xpeditis.com',
(process.env.BOOTSTRAP_ADMIN_EMAIL ?? '').trim().toLowerCase(),
],
]
);
for (const row of rows) {
let exposed = false;
try {
exposed = await argon2.verify(row.password_hash, 'Password123!');
} catch {
/* Invalid hashes cannot authenticate. */
}
if (!exposed) continue;
const hash = await argon2.hash(randomBytes(48).toString('hex'), {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Keep the last administrator active and preserve concurrent customized credentials.
// The changed hash invalidates existing access and refresh credentialVersions.
await queryRunner.query(
'UPDATE users SET password_hash=$2, updated_at=NOW() WHERE id=$1 AND password_hash=$3',
[row.id, hash, row.password_hash]
);
}
}
async down(): Promise<void> {
/* Never restore publicly known credentials. */
}
}

View File

@ -4,7 +4,11 @@ import {
WikiContribution, WikiContribution,
WikiContributionStatus, WikiContributionStatus,
} from '@domain/entities/wiki-contribution.entity'; } from '@domain/entities/wiki-contribution.entity';
import { WikiContributionRepository } from '@domain/ports/out/wiki-contribution.repository'; import {
WikiContributionConflict,
WikiContributionRepository,
WikiContributionWriter,
} from '@domain/ports/out/wiki-contribution.repository';
/** /**
* Pages du wiki ecrites a l'execution. * Pages du wiki ecrites a l'execution.
@ -60,46 +64,72 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
return rows.length ? toDomain(rows[0]) : null; return rows.length ? toDomain(rows[0]) : null;
} }
/** async save(
* Ecrit la page, ou remplace celle qui porte deja ce titre. contribution: WikiContribution,
* contributor?: WikiContributionWriter
* Le conflit est resolu en base et non par un `find` prealable : deux ): Promise<WikiContribution> {
* questions simultanees sur le meme sujet manquant produiraient sinon deux
* pages jumelles, l'index unique se contentant de faire echouer la seconde.
*/
async save(contribution: WikiContribution): Promise<WikiContribution> {
const page = contribution.toObject(); const page = contribution.toObject();
const rows: Row[] = await this.db.query( if (
`INSERT INTO wiki_contributions contributor &&
(id, locale, topic, title, section, body, status, author_user_id, author_organization_id, (page.authorUserId !== contributor.id ||
reviewed_by_user_id, reviewed_at, review_note, created_at, updated_at) page.authorOrganizationId !== contributor.organizationId ||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) page.status !== WikiContributionStatus.PENDING)
ON CONFLICT (locale, topic, lower(title)) DO UPDATE ) {
SET section = EXCLUDED.section, throw new WikiContributionConflict('Contribution not writable');
body = EXCLUDED.body, }
status = EXCLUDED.status, let rows: Row[];
reviewed_by_user_id = EXCLUDED.reviewed_by_user_id, if (page.version === 0) {
reviewed_at = EXCLUDED.reviewed_at, rows = await this.db.query(
review_note = EXCLUDED.review_note, `INSERT INTO wiki_contributions
updated_at = EXCLUDED.updated_at (id, locale, topic, title, section, body, status, author_user_id, author_organization_id,
RETURNING *`, reviewed_by_user_id, reviewed_at, review_note, created_at, updated_at, version)
[ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,1)
page.id, ON CONFLICT DO NOTHING RETURNING *`,
page.locale, [
page.topic, page.id,
page.title, page.locale,
page.section, page.topic,
page.body, page.title,
page.status, page.section,
page.authorUserId, page.body,
page.authorOrganizationId, page.status,
page.reviewedByUserId ?? null, page.authorUserId,
page.reviewedAt ?? null, page.authorOrganizationId,
page.reviewNote ?? null, page.reviewedByUserId ?? null,
page.createdAt, page.reviewedAt ?? null,
page.updatedAt, page.reviewNote ?? null,
] page.createdAt,
); page.updatedAt,
]
);
} else {
const [updatedRows]: [Row[], number] = await this.db.query(
`UPDATE wiki_contributions SET section=$3, body=$4, status=$5,
reviewed_by_user_id=$6, reviewed_at=$7, review_note=$8, updated_at=$9,
version=version+1
WHERE id=$1 AND version=$2
AND ($10::boolean = false OR (author_user_id=$11 AND author_organization_id=$12
AND status IN ('pending','rejected')))
RETURNING *`,
[
page.id,
page.version,
page.section,
page.body,
page.status,
page.reviewedByUserId ?? null,
page.reviewedAt ?? null,
page.reviewNote ?? null,
page.updatedAt,
!!contributor,
contributor?.id ?? null,
contributor?.organizationId ?? null,
]
);
rows = updatedRows;
}
if (!rows.length)
throw new WikiContributionConflict('Contribution changed or title already exists');
return toDomain(rows[0]); return toDomain(rows[0]);
} }
@ -116,6 +146,7 @@ export class TypeOrmWikiContributionRepository implements WikiContributionReposi
} }
interface Row { interface Row {
version: number;
id: string; id: string;
locale: string; locale: string;
topic: string; topic: string;
@ -139,6 +170,7 @@ interface Row {
function toDomain(row: Row): WikiContribution { function toDomain(row: Row): WikiContribution {
return WikiContribution.fromPersistence({ return WikiContribution.fromPersistence({
id: row.id, id: row.id,
version: row.version,
locale: row.locale, locale: row.locale,
topic: row.topic, topic: row.topic,
title: row.title, title: row.title,

View File

@ -58,14 +58,14 @@ describe('NeutralizeSeedAccountsInProduction1756000000000', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('ne touche a rien en preprod, meme avec NODE_ENV=production', async () => { it('controle aussi la preprod avec NODE_ENV=production', async () => {
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
process.env.APP_ENV = 'preprod'; process.env.APP_ENV = 'preprod';
const { runner, query } = fakeQueryRunner([]); const { runner, query } = fakeQueryRunner([]);
await migration.up(runner); await migration.up(runner);
expect(query).not.toHaveBeenCalled(); expect(query).toHaveBeenCalled();
}); });
it('en production, neutralise un compte au mot de passe public et conserve un compte personnalise', async () => { it('en production, neutralise un compte au mot de passe public et conserve un compte personnalise', async () => {

View File

@ -0,0 +1,182 @@
import { randomUUID } from 'crypto';
import { DataSource } from 'typeorm';
import * as argon2 from 'argon2';
import { WikiContribution } from '@domain/entities/wiki-contribution.entity';
import { WikiContributionConflict } from '@domain/ports/out/wiki-contribution.repository';
import { TypeOrmWikiContributionRepository } from './repositories/typeorm-wiki-contribution.repository';
import { CreateWikiContributions1789000000000 } from './migrations/1789000000000-CreateWikiContributions';
import { VersionWikiContributions1790000000003 } from './migrations/1790000000003-VersionWikiContributions';
import { RotatePublicDemoCredentials1790000000004 } from './migrations/1790000000004-RotatePublicDemoCredentials';
import { GuardPendingLegacyAdminBootstrap1755999999999 } from './migrations/1755999999999-GuardPendingLegacyAdminBootstrap';
import { AdminBootstrapService } from './admin-bootstrap.service';
// Explicit isolated Unix socket only; never fall back to the project's database.
const socket = process.env.XPEDITIS_SECURITY_TEST_SOCKET;
const isolated = !!socket && /^\/tmp\/xpeditis-audit-pg\.[A-Za-z0-9]+$/.test(socket);
(isolated ? describe : describe.skip)('PostgreSQL security boundaries (isolated cluster)', () => {
let db: DataSource;
let repo: TypeOrmWikiContributionRepository;
const author = { id: randomUUID(), organizationId: randomUUID() };
const intruder = { id: randomUUID(), organizationId: randomUUID() };
const admin = randomUUID();
const body =
'Le manifeste de cargaison décrit les marchandises transportées par voie maritime internationale. Le transitaire le transmet aux autorités douanières avant le chargement au port de départ. Les informations doivent être vérifiées pour éviter une immobilisation des marchandises au terminal.';
const make = (title = 'Le manifeste maritime', writer = author) =>
WikiContribution.create({
id: randomUUID(),
locale: 'fr',
topic: 'douanes',
title,
section: 'Transmission du manifeste',
body,
authorUserId: writer.id,
authorOrganizationId: writer.organizationId,
});
beforeAll(async () => {
db = new DataSource({
type: 'postgres',
host: socket,
port: 55437,
database: 'postgres',
extra: { max: 1 },
synchronize: false,
});
await db.initialize();
const schema = `audit_${randomUUID().replace(/-/g, '')}`;
await db.query(`CREATE SCHEMA "${schema}"`);
await db.query(`SET search_path TO "${schema}",public`);
await db.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public');
await db.query('ALTER EXTENSION "uuid-ossp" SET SCHEMA public');
await db.query(
'CREATE TABLE users (id uuid PRIMARY KEY, email text UNIQUE, role text, is_active boolean, password_hash text, last_login_at timestamptz, updated_at timestamptz)'
);
for (const id of [author.id, intruder.id, admin])
await db.query('INSERT INTO users(id) VALUES($1)', [id]);
const migrationRunner = db.createQueryRunner();
try {
await new CreateWikiContributions1789000000000().up(migrationRunner);
await new VersionWikiContributions1790000000003().up(migrationRunner);
} finally {
await migrationRunner.release();
}
repo = new TypeOrmWikiContributionRepository(db);
});
afterAll(async () => {
await db?.destroy();
});
beforeEach(async () => {
await db.query('DELETE FROM wiki_contributions');
});
it('does not overwrite an existing foreign title, including a concurrent case-insensitive collision', async () => {
const outcomes = await Promise.allSettled([
repo.save(make(), author),
repo.save(make('LE MANIFESTE MARITIME', intruder), intruder),
]);
expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1);
expect(outcomes.filter(o => o.status === 'rejected')).toHaveLength(1);
const pages = await repo.findForReview();
expect(pages).toHaveLength(1);
expect(pages[0].version).toBe(1);
});
it('allows own unpublished revision but rejects foreign and published writes at SQL boundary', async () => {
const original = await repo.save(make(), author);
await expect(
repo.save(original.revise('Autre section', body), intruder)
).rejects.toBeInstanceOf(WikiContributionConflict);
const revised = await repo.save(original.revise('Autre section', body), author);
expect(revised.version).toBe(2);
const published = await repo.save(revised.publish(admin));
await expect(repo.save(published.revise('Autre section', body), author)).rejects.toBeInstanceOf(
WikiContributionConflict
);
expect((await repo.findPublished('fr'))[0].version).toBe(3);
});
it('rejects a stale approval after a contributor revision without publishing unseen text', async () => {
const seen = await repo.save(make(), author);
await repo.save(seen.revise('Version révisée', body + ' Vérifiez les documents.'), author);
await expect(repo.save(seen.publish(admin))).rejects.toBeInstanceOf(WikiContributionConflict);
expect(await repo.findPublished('fr')).toHaveLength(0);
});
it('allows exactly one competing decision and rejects stale contributor writes', async () => {
const seen = await repo.save(make(), author);
const outcomes = await Promise.allSettled([
repo.save(seen.publish(admin)),
repo.save(seen.reject(admin, 'Refus')),
]);
expect(outcomes.filter(o => o.status === 'fulfilled')).toHaveLength(1);
await expect(repo.save(seen.revise('Version révisée', body), author)).rejects.toBeInstanceOf(
WikiContributionConflict
);
});
it('rotates known demo credentials without disabling the last admin or touching customized credentials', async () => {
const env = { NODE_ENV: process.env.NODE_ENV, APP_ENV: process.env.APP_ENV };
process.env.NODE_ENV = 'production';
process.env.APP_ENV = 'preprod';
const demo = await argon2.hash('Password123!');
const custom = await argon2.hash('test-only-personal-password-482');
await db.query(
'UPDATE users SET email=$2, role=$3, is_active=true, password_hash=$4 WHERE id=$1',
[admin, 'admin@xpeditis.com', 'ADMIN', demo]
);
await db.query(
'UPDATE users SET email=$2, role=$3, is_active=true, password_hash=$4 WHERE id=$1',
[author.id, 'user@xpeditis.com', 'USER', custom]
);
const runner = db.createQueryRunner();
await runner.startTransaction();
try {
await new RotatePublicDemoCredentials1790000000004().up(runner);
await runner.commitTransaction();
} finally {
await runner.release();
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
const [row] = await db.query('SELECT * FROM users WHERE id=$1', [admin]);
expect(row.role).toBe('ADMIN');
expect(row.is_active).toBe(true);
expect(await argon2.verify(row.password_hash, 'Password123!')).toBe(false);
expect(
(await db.query('SELECT password_hash FROM users WHERE id=$1', [author.id]))[0].password_hash
).toBe(custom);
});
it('does not promote a claimed bootstrap email during runtime startup', async () => {
await db.query(
'UPDATE users SET email=$2,role=$3,is_active=true,password_hash=$4,last_login_at=NOW() WHERE id=$1',
[intruder.id, 'claim@example.test', 'USER', 'attacker-hash']
);
const config = {
get: (key: string) => (key === 'BOOTSTRAP_ADMIN_EMAIL' ? 'claim@example.test' : undefined),
};
await new AdminBootstrapService(config as never, db).run();
const [row] = await db.query('SELECT role,password_hash FROM users WHERE id=$1', [intruder.id]);
expect(row).toEqual({ role: 'USER', password_hash: 'attacker-hash' });
});
it('blocks the pending immutable legacy bootstrap before it could promote an email claimant', async () => {
await db.query('CREATE TABLE migrations (name text)');
const old = process.env.BOOTSTRAP_ADMIN_EMAIL;
process.env.BOOTSTRAP_ADMIN_EMAIL = 'claim@example.test';
const runner = db.createQueryRunner();
await runner.startTransaction();
try {
await expect(new GuardPendingLegacyAdminBootstrap1755999999999().up(runner)).rejects.toThrow(
'existing non-administrator'
);
} finally {
await runner.rollbackTransaction();
await runner.release();
if (old === undefined) delete process.env.BOOTSTRAP_ADMIN_EMAIL;
else process.env.BOOTSTRAP_ADMIN_EMAIL = old;
}
});
});

View File

@ -52,8 +52,15 @@ export default function WikiReviewPage() {
}; };
const publish = useMutation({ const publish = useMutation({
mutationFn: ({ id, body }: { id: string; body?: string }) => mutationFn: ({
publishWikiContribution(id, body ? { body } : undefined), id,
expectedVersion,
body,
}: {
id: string;
expectedVersion: number;
body?: string;
}) => publishWikiContribution(id, { expectedVersion, body }),
onSuccess: page => { onSuccess: page => {
toast.success(t('publishDone', { title: page.title })); toast.success(t('publishDone', { title: page.title }));
refresh(); refresh();
@ -62,7 +69,15 @@ export default function WikiReviewPage() {
}); });
const reject = useMutation({ const reject = useMutation({
mutationFn: ({ id, note }: { id: string; note?: string }) => rejectWikiContribution(id, note), mutationFn: ({
id,
expectedVersion,
note,
}: {
id: string;
expectedVersion: number;
note?: string;
}) => rejectWikiContribution(id, expectedVersion, note),
onSuccess: page => { onSuccess: page => {
toast.success(t('rejectDone', { title: page.title })); toast.success(t('rejectDone', { title: page.title }));
refresh(); refresh();
@ -114,12 +129,12 @@ export default function WikiReviewPage() {
) : ( ) : (
pages.map(page => ( pages.map(page => (
<ReviewCard <ReviewCard
key={page.id} key={`${page.id}:${page.version}`}
page={page} page={page}
locale={locale} locale={locale}
busy={publish.isPending || reject.isPending} busy={publish.isPending || reject.isPending}
onPublish={(id, body) => publish.mutate({ id, body })} onPublish={(id, body) => publish.mutate({ id, expectedVersion: page.version, body })}
onReject={(id, note) => reject.mutate({ id, note })} onReject={(id, note) => reject.mutate({ id, expectedVersion: page.version, note })}
/> />
)) ))
)} )}

View File

@ -78,6 +78,7 @@ export type WikiContributionStatus = 'pending' | 'published' | 'rejected';
/** La même page, vue depuis la file de relecture : statut et décision compris. */ /** La même page, vue depuis la file de relecture : statut et décision compris. */
export interface WikiContributionReview extends Omit<WikiContribution, 'href'> { export interface WikiContributionReview extends Omit<WikiContribution, 'href'> {
version: number;
locale: string; locale: string;
status: WikiContributionStatus; status: WikiContributionStatus;
/** `null` tant que la page n'est pas publiée : le lien pointerait vers du vide. */ /** `null` tant que la page n'est pas publiée : le lien pointerait vers du vide. */
@ -106,11 +107,16 @@ export const getWikiContributions = (language: string) =>
export const getWikiReviewQueue = (status?: WikiContributionStatus) => export const getWikiReviewQueue = (status?: WikiContributionStatus) =>
get<WikiReviewQueue>(`/api/v1/admin/wiki-contributions${status ? `?status=${status}` : ''}`); get<WikiReviewQueue>(`/api/v1/admin/wiki-contributions${status ? `?status=${status}` : ''}`);
export const publishWikiContribution = (id: string, edits?: { section?: string; body?: string }) => export const publishWikiContribution = (
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/publish`, edits ?? {}); id: string,
edits: { expectedVersion: number; section?: string; body?: string }
) => post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/publish`, edits);
export const rejectWikiContribution = (id: string, note?: string) => export const rejectWikiContribution = (id: string, expectedVersion: number, note?: string) =>
post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/reject`, { note }); post<WikiContributionReview>(`/api/v1/admin/wiki-contributions/${id}/reject`, {
expectedVersion,
note,
});
export const getTradeConversations = () => export const getTradeConversations = () =>
get<TradeConversation[]>('/api/v1/trade-assistant/conversations'); get<TradeConversation[]>('/api/v1/trade-assistant/conversations');

View File

@ -0,0 +1,52 @@
# Fusion de check_secu dans ia — sécurité, 22 septembre 2026
## Résultat et périmètre
Les corrections antérieures ont été commités sur `check_secu` dans `a0ac0379ebcad0cda28e177332d3271d450dfce0`. La branche `ia`, issue de `origin/ia` à `c35f3d7`, les a reçues dans le merge `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les conflits ont été résolus en conservant les contrôles de sécurité et les fonctionnalités IA.
Le scan `262023d3-7c39-4253-961a-a4175bdf5d72` examine exactement **a0ac037..ef0d7d5** : **110 fichiers de code/configuration modifiés**, répartis en 28 fichiers assistant/MCP/wiki, 40 fichiers authentification/démarrage et 42 autres fichiers réservation/interface/configuration. Les chemins de soutien nécessaires ont également été consultés. Quatre failles ont été confirmées ; les correctifs suivants sont destinés au commit de sécurité sur `ia` qui contient ce document.
| ID | Gravité historique | Correctif |
| --- | --- | --- |
| [SEC-20](wiki-propriete/wiki-propriete.md) | Moyenne | Propriété auteur/organisation, refus des pages publiées et écritures SQL conditionnelles |
| [SEC-21](wiki-version-revue/wiki-version-revue.md) | Moyenne | Version effectivement relue exigée et contrôle atomique des conflits |
| [SEC-22](identifiants-demo-preprod/identifiants-demo-preprod.md) | Élevée | Préproduction protégée, rotation des identifiants publics, garde avant le bootstrap historique |
| [SEC-23](bootstrap-identite/bootstrap-identite.md) | Élevée | Aucun octroi du rôle ADMIN à un compte existant sur la seule égalité de son email |
Les scripts de démarrage/migration utilisent aussi le logger SQL sécurisé et des messages d’échec génériques afin de ne pas journaliser les nouveaux hashes lors d’une erreur (complément SEC-07).
## Preuves et tests
Six assertions de régression échouaient sur le code fusionné vulnérable. Après correction :
- Backend : **734 tests réussis, 5 ignorés** ; 57 suites réussies et une suite ignorée.
- Parmi eux, **7 tests sur un PostgreSQL temporaire isolé** vérifient collisions de titres, propriété, version périmée, décisions concurrentes, rotation des identifiants et gardes bootstrap.
- Frontend : **191 tests réussis** ; vérification TypeScript réussie.
- Compilation backend et lint ciblé des fichiers TypeScript backend modifiés : réussis.
- Contrôle des espaces du patch sécurité : réussi ; les modifications utilisateur exclues du commit ne sont pas reformatées.
Une première exécution dans le bac à sable ne pouvait pas ouvrir les sockets locales (`EPERM`). La relance autorisée a réussi. Aucun serveur SMTP réel, service Stripe ou environnement de production n’a été utilisé. Le PostgreSQL de test utilise exclusivement un socket local et une base temporaire ; aucune migration de la base du projet n’a été exécutée.
Une revue indépendante du patch a identifié un cas résiduel : un bootstrap configuré avec une adresse différente des comptes de démonstration pouvait encore conserver le mot de passe public via la migration historique. Le garde préalable, la rotation de l’adresse configurée et un test Argon2 couvrent désormais ce chemin. Le test PostgreSQL a également permis de corriger l’interprétation du résultat UPDATE de TypeORM.
## Mise en service et compatibilité
Trois nouvelles migrations sont ajoutées, sans modifier les migrations historiques déjà appliquées :
- `GuardPendingLegacyAdminBootstrap1755999999999`, volontairement ordonnée avant le bootstrap historique encore en attente : refuse une adresse déjà occupée par un non-administrateur, exige une transaction et verrouille les utilisateurs pendant ce passage.
- `VersionWikiContributions1790000000003` : version positive initialisée à 1 sur les propositions existantes.
- `RotatePublicDemoCredentials1790000000004` : remplace uniquement les hashes correspondant au mot de passe public connu, conserve les mots de passe personnalisés et laisse le dernier administrateur actif. Un compte concerné doit récupérer/configurer un mot de passe sûr avant connexion ; le mot de passe aléatoire de remplacement n’est pas communiqué.
Les runners fournis imposent `migrationsTransactionMode: all`. La nouvelle interface wiki et l’API doivent être déployées ensemble : publication/rejet sans `expectedVersion` sont refusés, et une version obsolète reçoit 409. Une ancienne page ouverte doit être rechargée. Un auteur peut réviser sa proposition non publiée ; il ne peut plus remettre lui-même une page publiée en attente.
Si une adresse bootstrap est déjà utilisée par un non-administrateur, le déploiement doit choisir une adresse libre ou passer par une procédure administrative autorisée. Le service ne convertit pas automatiquement ce compte en administrateur.
## Limites et point de politique à confirmer
Cette passe couvre intégralement le **diff identifié**, pas tous les fichiers historiques du monorepo ni toutes les combinaisons d’exécution. Les cinq tests ignorés restent une limite de la suite. Les fichiers `.env` et `.env.*` n’ont pas été lus. L’audit de dépendances, les configurations effectives, les politiques réseau externes et le site en ligne ne sont pas vérifiés. Aucune révocation fournisseur ou publication distante n’a été effectuée.
**SEC-12 : politique modifiée par ia.** Le code IA permet explicitement la lecture d’un dossier individuel d’un collègue de la même organisation ; les listes et statistiques globales restent limitées aux rôles de gestion. Cette évolution intentionnelle ne permet pas d’affirmer que l’ancienne restriction « propriétaire uniquement » est toujours présente. La politique produit attendue doit être confirmée. L’interface qui demande une liste globale avec un rôle ordinaire peut recevoir un 403 ; le garde n’a pas été retiré pour contourner cette incompatibilité.
Les actions opérationnelles SEC-05, SEC-10 et SEC-19 restent ouvertes : anciens jetons distribués, secrets SMTP et clé API retirée du code ne sont pas automatiquement révoqués chez leurs fournisseurs. Le fichier `docker/stack-portainer-preprod.yaml` contient des modifications utilisateur conservées hors du nouveau commit ; la sauvegarde stash antérieure est conservée.
Aucun push, déploiement ou migration de production n’est inclus dans ce travail.

View File

@ -1,13 +1,17 @@
# Audit de sécurité — Xpeditis # Audit de sécurité — Xpeditis
Ce dossier rassemble **19 constats de sécurité connus**, chacun expliqué dans une fiche, et **2 observations à valider**. Il décrit l'état du code, mis à jour le **17 septembre 2026**, sur la branche `check_secu`. Ce dossier rassemble **23 constats de sécurité connus**, chacun expliqué dans une fiche, et **2 observations à valider**. Il décrit l'état du code, mis à jour le **22 septembre 2026**, sur la branche `ia`.
**Ce n'est pas une liste exhaustive de toutes les failles possibles ni une attestation de sécurité de la production.** Une grande partie des constats a déjà été corrigée dans le code ; les fiches expliquent le problème historique, la preuve, les limites et le risque restant. Après la passe documentaire, les corrections restantes ont été demandées et traitées le 17 septembre ; voir le [compte rendu](CORRECTIONS-2026-09-17.md). **Ce n'est pas une liste exhaustive de toutes les failles possibles ni une attestation de sécurité de la production.** Une grande partie des constats a déjà été corrigée dans le code ; les fiches expliquent le problème historique, la preuve, les limites et le risque restant. Après la passe documentaire, les corrections restantes ont été demandées et traitées le 17 septembre ; voir le [compte rendu](CORRECTIONS-2026-09-17.md).
## État de la poursuite — 15 septembre 2026 ## Historique de la poursuite — 15 septembre 2026
La nouvelle tentative d'audit approfondi de tout le dépôt a échoué sur une limite d'usage après trois workers de découverte infructueux. Aucun manifeste réussi ni identifiant de scan n'a été retourné ; les éventuels résultats intermédiaires ne sont donc pas consultables depuis cette réponse. **La couverture reste partielle, sans nouveau constat validé à ajouter.** Les 18 constats et 2 observations ci-dessous restent ceux documentés précédemment. Voir le [journal de poursuite](JOURNAL.md) pour l'erreur exacte et les limites. La nouvelle tentative d'audit approfondi de tout le dépôt a échoué sur une limite d'usage après trois workers de découverte infructueux. Aucun manifeste réussi ni identifiant de scan n'a été retourné ; les éventuels résultats intermédiaires ne sont donc pas consultables depuis cette réponse. **La couverture reste partielle, sans nouveau constat validé à ajouter.** Les 18 constats et 2 observations ci-dessous restent ceux documentés précédemment. Voir le [journal de poursuite](JOURNAL.md) pour l'erreur exacte et les limites.
## Fusion et corrections — 22 septembre 2026
Les correctifs précédents ont été commités (`a0ac037`), puis fusionnés dans `ia` (`ef0d7d5`). La revue des 110 fichiers de code/configuration modifiés par cette fusion a confirmé quatre nouvelles failles, corrigées et détaillées dans le [compte rendu](CORRECTIONS-FUSION-IA-2026-09-22.md). Cette couverture du diff ne constitue pas un nouvel audit exhaustif de tout le dépôt.
## Corrections — 17 septembre 2026 ## Corrections — 17 septembre 2026
SEC-07 est corrigé dans le code local, y compris les logs HTTP/SMTP/TypeORM. OBS-01 refuse désormais un tarif inconnu avant effets de bord ; ses cas de panne et ses contrôles légitimes sont testés. OBS-02 reste une observation : les tests du pipe confirment le refus actuel de création/modification. SEC-19 documente une clé d'API retirée de la configuration locale, dont la révocation externe reste nécessaire. Aucun déploiement n'est effectué. SEC-07 est corrigé dans le code local, y compris les logs HTTP/SMTP/TypeORM. OBS-01 refuse désormais un tarif inconnu avant effets de bord ; ses cas de panne et ses contrôles légitimes sont testés. OBS-02 reste une observation : les tests du pipe confirment le refus actuel de création/modification. SEC-19 documente une clé d'API retirée de la configuration locale, dont la révocation externe reste nécessaire. Aucun déploiement n'est effectué.
@ -16,7 +20,7 @@ SEC-07 est corrigé dans le code local, y compris les logs HTTP/SMTP/TypeORM. OB
- **SEC-07 : correction locale appliquée.** Les credentials ne doivent plus être recopiés par les chemins de journalisation corrigés ; les secrets déjà présents dans les anciens logs restent à traiter. - **SEC-07 : correction locale appliquée.** Les credentials ne doivent plus être recopiés par les chemins de journalisation corrigés ; les secrets déjà présents dans les anciens logs restent à traiter.
- **Actions opérationnelles non vérifiées : SEC-05, SEC-10 et SEC-19.** La suppression d'un token dans une réponse ou d'un secret SMTP dans le dernier fichier ne révoque pas les copies déjà distribuées. - **Actions opérationnelles non vérifiées : SEC-05, SEC-10 et SEC-19.** La suppression d'un token dans une réponse ou d'un secret SMTP dans le dernier fichier ne révoque pas les copies déjà distribuées.
- **Version et configuration en ligne inconnues : SEC-15/16/17/18.** Les correctifs TLS principaux sont suivis dans Git ; les correctifs Stripe et droits d'abonnement sont locaux au moment de cette rédaction. Aucun déploiement n'est confirmé. - **Version et configuration en ligne inconnues : SEC-15/16/17/18.** Les correctifs TLS principaux sont suivis dans Git ; les correctifs Stripe et droits d’abonnement sont commités dans `a0ac037` et fusionnés dans `ia`. Aucun déploiement n'est confirmé.
- **OBS-01 corrigé ; OBS-02 à surveiller.** La gestion du tarif inconnu est corrigée et testée. Une éventuelle voie d’écriture de destination webhook reste à établir. - **OBS-01 corrigé ; OBS-02 à surveiller.** La gestion du tarif inconnu est corrigée et testée. Une éventuelle voie d’écriture de destination webhook reste à établir.
## Index des constats ## Index des constats
@ -36,14 +40,18 @@ La gravité décrit le comportement vulnérable avant correction. Elle ne doit p
| SEC-09 | [Les téléversements ne bornent pas la mémoire utilisée](televersements-memoire/televersements-memoire.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | | SEC-09 | [Les téléversements ne bornent pas la mémoire utilisée](televersements-memoire/televersements-memoire.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-10 | [Une clé SMTP figure dans un fichier suivi](secret-smtp-versionne/secret-smtp-versionne.md) | Moyenne | Littéral retiré ; rotation fournisseur non vérifiée | | SEC-10 | [Une clé SMTP figure dans un fichier suivi](secret-smtp-versionne/secret-smtp-versionne.md) | Moyenne | Littéral retiré ; rotation fournisseur non vérifiée |
| SEC-11 | [La résiliation peut conserver les avantages payants](resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md) | Moyenne | Corrigé dans Git ; déploiement inconnu | | SEC-11 | [La résiliation peut conserver les avantages payants](resiliation-bloquee-par-licences/resiliation-bloquee-par-licences.md) | Moyenne | Corrigé dans Git ; déploiement inconnu |
| SEC-12 | [Les dossiers des collègues sont accessibles sans rôle de gestion](lecture-dossiers-collegues/lecture-dossiers-collegues.md) | Faible | Corrigé dans Git ; déploiement inconnu | | SEC-12 | [Les dossiers des collègues sont accessibles sans rôle de gestion](lecture-dossiers-collegues/lecture-dossiers-collegues.md) | Faible | Politique modifiée par ia : voir le compte rendu de fusion |
| SEC-13 | [Un manager peut rétrograder un administrateur de son organisation](manager-modifie-administrateur/manager-modifie-administrateur.md) | Faible | Corrigé dans Git ; déploiement inconnu | | SEC-13 | [Un manager peut rétrograder un administrateur de son organisation](manager-modifie-administrateur/manager-modifie-administrateur.md) | Faible | Corrigé dans Git ; déploiement inconnu |
| SEC-14 | [Les exports CSV conservent les formules injectées](injection-formules-csv/injection-formules-csv.md) | Faible | Corrigé dans Git ; déploiement inconnu | | SEC-14 | [Les exports CSV conservent les formules injectées](injection-formules-csv/injection-formules-csv.md) | Faible | Corrigé dans Git ; déploiement inconnu |
| SEC-15 | [SMTP : identité du serveur non vérifiée et STARTTLS facultatif](smtp-tls-non-verifie/smtp-tls-non-verifie.md) | Moyenne | Code corrigé ; configuration effective à confirmer | | SEC-15 | [SMTP : identité du serveur non vérifiée et STARTTLS facultatif](smtp-tls-non-verifie/smtp-tls-non-verifie.md) | Moyenne | Code corrigé ; configuration effective à confirmer |
| SEC-16 | [PostgreSQL : TLS incohérent et certificat non authentifié](postgresql-tls-incoherent/postgresql-tls-incoherent.md) | Moyenne | Chemins principaux corrigés ; CA/scripts à vérifier | | SEC-16 | [PostgreSQL : TLS incohérent et certificat non authentifié](postgresql-tls-incoherent/postgresql-tls-incoherent.md) | Moyenne | Chemins principaux corrigés ; CA/scripts à vérifier |
| SEC-17 | [Stripe : session Checkout non liée à son organisation](stripe-session-organisation/stripe-session-organisation.md) | Moyenne | Correctif local non commité | | SEC-17 | [Stripe : session Checkout non liée à son organisation](stripe-session-organisation/stripe-session-organisation.md) | Moyenne | Corrigé dans Git (a0ac037) ; déploiement inconnu |
| SEC-18 | [Droits payants conservés sur un abonnement inactif](droits-abonnements-inactifs/droits-abonnements-inactifs.md) | Moyenne | Correctif local non commité | | SEC-18 | [Droits payants conservés sur un abonnement inactif](droits-abonnements-inactifs/droits-abonnements-inactifs.md) | Moyenne | Corrigé dans Git (a0ac037) ; déploiement inconnu |
| SEC-19 | [Clé d’API littérale en préproduction](cle-api-preproduction/cle-api-preproduction.md) | Moyenne, validité inconnue | Littéral retiré ; révocation externe nécessaire | | SEC-19 | [Clé d’API littérale en préproduction](cle-api-preproduction/cle-api-preproduction.md) | Moyenne, validité inconnue | Littéral retiré ; révocation externe nécessaire |
| SEC-20 | [Écrasement et retrait de contributions wiki étrangères](wiki-propriete/wiki-propriete.md) | Moyenne | Corrigé sur ia ; déploiement inconnu |
| SEC-21 | [Publication d’une version non relue par l’administrateur](wiki-version-revue/wiki-version-revue.md) | Moyenne | Corrigé sur ia ; déploiement inconnu |
| SEC-22 | [Comptes de démonstration utilisables en préproduction](identifiants-demo-preprod/identifiants-demo-preprod.md) | Élevée | Corrigé sur ia ; déploiement inconnu |
| SEC-23 | [Promotion administrative par récupération d’une adresse bootstrap](bootstrap-identite/bootstrap-identite.md) | Élevée | Corrigé sur ia ; déploiement inconnu |
## Suivi des observations initiales ## Suivi des observations initiales

View File

@ -0,0 +1,29 @@
# SEC-23 — Promotion administrative par récupération d’une adresse bootstrap
Date : 22 septembre 2026. Gravité historique : **Élevée** (CWE-269 / CWE-287). Statut : corrigé dans le code de `ia`, déploiement non vérifié.
## Scénario et conditions
Après suppression/anonymisation légitime du compte bootstrap, son adresse peut redevenir libre. Un attaquant l’enregistre et se connecte avant un redémarrage. Le nouveau bootstrap retrouvait le compte par email, lui attribuait ADMIN et conservait son mot de passe lorsqu’une connexion était déjà enregistrée.
## Impact et limites
Élévation jusqu’au rôle administrateur global sans preuve de possession de l’identité opérateur. Nécessite une adresse bootstrap configurée libérée puis revendiquée avant le redémarrage. Il ne s’agit pas d’une prise de contrôle démontrée sur le site déployé.
## Preuves dans la version vulnérable
Révision immuable examinée : `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les numéros suivants désignent cette révision, pas les lignes du correctif :
`apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts:100,195,230 ; apps/backend/src/application/auth/auth.service.ts:213`
Le traçage des entrées, contrôles et écritures a été complété par des régressions locales : six assertions de sécurité échouaient avant correction, couvrant les quatre constats de cette passe. Il ne s’agit pas d’une reproduction HTTP sur la production.
## Correction
Aucun compte existant non-administrateur ne peut être promu ou réactivé par ce mécanisme, même avec le drapeau de réinitialisation. Le compte est verrouillé et la mise à jour exige déjà le rôle ADMIN. La création sur une adresse libre reste possible. Une nouvelle migration de garde, ordonnée avant le bootstrap historique non encore appliqué, refuse une adresse occupée par un non-administrateur et maintient un verrou jusqu’à la fin de la transaction globale.
## Vérification
Régressions avec et sans réinitialisation, vérification du refus au démarrage sur PostgreSQL et refus du bootstrap historique encore en attente. Les migrations déjà appliquées ne sont pas modifiées ; les trois runners utilisent le mode transactionnel all.
Voir le [compte rendu et les limites de couverture](../CORRECTIONS-FUSION-IA-2026-09-22.md). Aucun fichier `.env` n’a été lu, aucun service de production n’a été contacté, aucune migration n’a été appliquée à la base du projet pendant ces tests.

View File

@ -0,0 +1,29 @@
# SEC-22 — Comptes de démonstration utilisables en préproduction
Date : 22 septembre 2026. Gravité historique : **Élevée** (CWE-798). Statut : corrigé dans le code de `ia`, déploiement non vérifié.
## Scénario et conditions
Le classificateur introduit par ia considérait APP_ENV=preprod comme non-production même avec NODE_ENV=production. Sur une base neuve ou avant neutralisation, les migrations pouvaient créer/conserver les comptes de démonstration actifs avec leur mot de passe public. La configuration de routage examinée n’imposait pas de restriction d’accès externe.
## Impact et limites
Un client pouvant atteindre la connexion pouvait obtenir le rôle ADMIN avec les identifiants publics sur ce cycle de déploiement. Les bases déjà migrées ne rejouent pas automatiquement ces migrations ; leur état réel n’a pas été consulté.
## Preuves dans la version vulnérable
Révision immuable examinée : `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les numéros suivants désignent cette révision, pas les lignes du correctif :
`apps/backend/src/infrastructure/persistence/typeorm/deployment-environment.ts:19 ; apps/backend/src/infrastructure/persistence/typeorm/migrations/1730000000007-SeedTestUsers.ts:35 ; apps/backend/src/infrastructure/persistence/typeorm/migrations/1756000000000-NeutralizeSeedAccountsInProduction.ts:113 ; infra/preprod/docker-stack.preprod.yml:153`
Le traçage des entrées, contrôles et écritures a été complété par des régressions locales : six assertions de sécurité échouaient avant correction, couvrant les quatre constats de cette passe. Il ne s’agit pas d’une reproduction HTTP sur la production.
## Correction
NODE_ENV=production impose la protection. Hors production explicite, seuls dev/development/local/test désactivent cette protection ; préproduction, staging et valeurs absentes/inconnues sont protégés. Une nouvelle migration remplace les mots de passe publics encore présents des comptes de démonstration et de l’adresse bootstrap configurée, en conservant les mots de passe personnalisés et le dernier administrateur actif. Un hash du mot de passe public est aussi refusé avant le bootstrap historique et au démarrage.
## Vérification
Tests du classificateur et des migrations, rotation réelle Argon2 sur PostgreSQL, préservation d’un mot de passe personnalisé et du rôle actif. La revue indépendante a révélé le cas d’une adresse bootstrap différente des comptes de démonstration : le garde préalable et la rotation couvrent également cette adresse. Les sessions liées à l’ancien hash deviennent invalides.
Voir le [compte rendu et les limites de couverture](../CORRECTIONS-FUSION-IA-2026-09-22.md). Aucun fichier `.env` n’a été lu, aucun service de production n’a été contacté, aucune migration n’a été appliquée à la base du projet pendant ces tests.

View File

@ -62,3 +62,8 @@ Pour solder le constat en exploitation, vérifier le comportement sur la version
## Summary ## Summary
Les dossiers des collègues sont accessibles sans rôle de gestion est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. Les dossiers des collègues sont accessibles sans rôle de gestion est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet.
## Mise à jour après fusion ia — 22 septembre 2026
La restriction historique ne décrit plus exactement le comportement fusionné : ia autorise explicitement les lectures individuelles dans la même organisation, tandis que listes/statistiques restent restreintes. Voir le [compte rendu de fusion](../CORRECTIONS-FUSION-IA-2026-09-22.md). Politique produit à confirmer ; déploiement inconnu.

View File

@ -64,3 +64,8 @@ Pour solder le constat en exploitation, vérifier le comportement sur la version
## Summary ## Summary
Les logs contiennent mots de passe et invitations est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet. Les logs contiennent mots de passe et invitations est un constat historique de l'audit, à lire avec son état courant ci-dessus. La preuve porte sur le mécanisme et les contrôles cités ; elle ne constitue ni une attestation d'exploitation réelle ni une certification exhaustive du projet.
## Complément — 22 septembre 2026
Les runners startup.js et run-migrations.js utilisent également SafeDatabaseLogger et ne recopient plus les erreurs SQL brutes, afin de protéger notamment les hashes de rotation lors des migrations. Compilation et tests database-startup réussis.

View File

@ -0,0 +1,29 @@
# SEC-20 — Écrasement et retrait de contributions wiki étrangères
Date : 22 septembre 2026. Gravité historique : **Moyenne** (CWE-639 / CWE-862). Statut : corrigé dans le code de `ia`, déploiement non vérifié.
## Scénario et conditions
Un utilisateur authentifié pouvant proposer une contribution choisit le titre d’une page existante, appartenant à un autre auteur ou déjà publiée. La recherche globale par titre puis la révision remplaçaient le contenu et remettaient la page en attente, sans contrôler auteur, organisation ou statut.
## Impact et limites
Le contenu partagé pouvait être détruit ou retiré de la recherche globale, avec conservation trompeuse de l’auteur initial. Ce chemin ne publiait pas directement un nouveau texte sans administrateur.
## Preuves dans la version vulnérable
Révision immuable examinée : `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les numéros suivants désignent cette révision, pas les lignes du correctif :
`apps/backend/src/application/mcp/capabilities/knowledge.capabilities.ts:106 ; apps/backend/src/domain/entities/wiki-contribution.entity.ts:114 ; apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-wiki-contribution.repository.ts:56`
Le traçage des entrées, contrôles et écritures a été complété par des régressions locales : six assertions de sécurité échouaient avant correction, couvrant les quatre constats de cette passe. Il ne s’agit pas d’une reproduction HTTP sur la production.
## Correction
Le service impose auteur et organisation identiques et refuse toute modification d’une page publiée. Le dépôt répète les contrôles dans les prédicats SQL. La création utilise INSERT ON CONFLICT DO NOTHING ; une collision de titre ne remplace plus une page existante.
## Vérification
Tests de refus des pages étrangères/en ligne, contrôle positif de sa propre proposition, collision simultanée de titres insensible à la casse et contrôle des prédicats sur PostgreSQL.
Voir le [compte rendu et les limites de couverture](../CORRECTIONS-FUSION-IA-2026-09-22.md). Aucun fichier `.env` n’a été lu, aucun service de production n’a été contacté, aucune migration n’a été appliquée à la base du projet pendant ces tests.

View File

@ -0,0 +1,29 @@
# SEC-21 — Publication d’une version non relue par l’administrateur
Date : 22 septembre 2026. Gravité historique : **Moyenne** (CWE-367). Statut : corrigé dans le code de `ia`, déploiement non vérifié.
## Scénario et conditions
L’administrateur charge une proposition bénigne. Son auteur la modifie ensuite. Le bouton Publier envoyait seulement l’identifiant ; le serveur rechargeait et publiait le texte courant, différent du texte réellement affiché au relecteur.
## Impact et limites
Contournement de la relecture humaine et pollution de documentation partagée utilisée par l’assistant. Aucune exécution de code ni compromission déterministe du modèle n’est démontrée. Une action de publication de l’administrateur reste nécessaire.
## Preuves dans la version vulnérable
Révision immuable examinée : `ef0d7d5f670638a2dcce21f6aea8e169513b91ec`. Les numéros suivants désignent cette révision, pas les lignes du correctif :
`apps/frontend/app/[locale]/admin/wiki/page.tsx:54 ; apps/backend/src/application/trade-assistant/wiki-review.controller.ts:23 ; apps/backend/src/application/trade-assistant/wiki-review.service.ts:58`
Le traçage des entrées, contrôles et écritures a été complété par des régressions locales : six assertions de sécurité échouaient avant correction, couvrant les quatre constats de cette passe. Il ne s’agit pas d’une reproduction HTTP sur la production.
## Correction
Chaque proposition porte une version persistée. Publication et rejet exigent expectedVersion, entier positif ; le serveur compare la version lue puis effectue une écriture conditionnelle atomique. Une modification concurrente produit un conflit HTTP 409. L’interface envoie la version affichée et réinitialise les champs lors du changement de version.
## Vérification
Test de version obsolète sans publication, contrôle positif de publication, décisions concurrentes avec un seul gagnant et refus d’une ancienne révision sur PostgreSQL. La migration VersionWikiContributions initialise les lignes existantes à 1.
Voir le [compte rendu et les limites de couverture](../CORRECTIONS-FUSION-IA-2026-09-22.md). Aucun fichier `.env` n’a été lu, aucun service de production n’a été contacté, aucune migration n’a été appliquée à la base du projet pendant ces tests.