xpeditis2.0/docs/security/check-secu/findings.json
2026-09-14 11:19:29 +02:00

2615 lines
152 KiB
JSON

{
"documentType": "codex-security.findings",
"findings": [
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5"
],
"sink": "apps/frontend/src/lib/context/auth-context.tsx",
"source": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL",
"summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5"
],
"reachability": {
"attacker": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL",
"entrypoint": "apps/frontend/app/[locale]/login/page.tsx",
"summary": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically."
},
"summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication."
},
"codeEvidence": [
{
"code": " const { login } = useAuth();\n const searchParams = useSearchParams();\n const redirectTo = searchParams.get('redirect') || '/dashboard';\n const tLogin = useTranslations('auth.login');\n const tPanel = useTranslations('auth.sidePanel');",
"endLine": 99,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/frontend/app/[locale]/login/page.tsx",
"role": "evidence",
"startLine": 95
},
{
"code": " setIsLoading(true);\n\n try {\n await login(email, password, redirectTo, rememberMe);\n } catch (err: any) {\n const { message, field } = mapLoginError(err, tLogin);",
"endLine": 167,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/frontend/app/[locale]/login/page.tsx",
"role": "evidence",
"startLine": 162
},
{
"code": " try {\n await apiLogin({ email, password, rememberMe });\n // Fetch complete user profile after login (session lives in httpOnly cookies)\n const currentUser = await getCurrentUser();\n setUser(currentUser);\n router.push(redirectTo);",
"endLine": 110,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/frontend/src/lib/context/auth-context.tsx",
"role": "root_control",
"startLine": 105
},
{
"code": "function useNavigate(dispatch) {\n return (0, _react.useCallback)((href, navigateType, shouldScroll)=>{\n const url = new URL((0, _addbasepath.addBasePath)(href), location.href);\n return dispatch({\n type: _routerreducertypes.ACTION_NAVIGATE,\n url,\n isExternalUrl: isExternalURL(url),\n locationSearch: location.search,\n shouldScroll: shouldScroll != null ? shouldScroll : true,",
"endLine": 175,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/frontend/node_modules/next/dist/client/components/app-router.js",
"role": "evidence",
"startLine": 167
},
{
"code": " const pendingPush = navigateType === \"push\";\n // we want to prune the prefetch cache on every navigation to avoid it growing too large\n (0, _prefetchcacheutils.prunePrefetchCache)(state.prefetchCache);\n mutable.preserveCustomHistoryState = false;\n if (isExternalUrl) {\n return handleExternalUrl(state, mutable, url.toString(), pendingPush);\n }\n const prefetchValues = (0, _prefetchcacheutils.getOrCreatePrefetchCacheEntry)({",
"endLine": 105,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e4",
"label": "Source 5",
"path": "apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js",
"role": "evidence",
"startLine": 98
},
{
"code": " if (pushRef.mpaNavigation) {\n // if there's a re-render, we don't want to trigger another redirect if one is already in flight to the same URL\n if (globalMutable.pendingMpaPath !== canonicalUrl) {\n const location1 = window.location;\n if (pushRef.pendingPush) {\n location1.assign(canonicalUrl);\n } else {\n location1.replace(canonicalUrl);",
"endLine": 403,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e5",
"label": "Source 6",
"path": "apps/frontend/node_modules/next/dist/client/components/app-router.js",
"role": "evidence",
"startLine": 396
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_920528f644bd65099e2bac54",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:41eed20bc71a10cbe19b8b489995cf4d337d4ccd8722484a88070bd9ef3b94d1"
},
"identity": {
"anchor": "la-redirection-de-connexion-permet-une-xss-dom"
},
"locations": [
{
"endLine": 99,
"path": "apps/frontend/app/[locale]/login/page.tsx",
"role": "evidence",
"startLine": 95
},
{
"endLine": 167,
"path": "apps/frontend/app/[locale]/login/page.tsx",
"role": "evidence",
"startLine": 162
},
{
"endLine": 110,
"path": "apps/frontend/src/lib/context/auth-context.tsx",
"role": "root_control",
"startLine": 105
},
{
"endLine": 175,
"path": "apps/frontend/node_modules/next/dist/client/components/app-router.js",
"role": "evidence",
"startLine": 167
},
{
"endLine": 105,
"path": "apps/frontend/node_modules/next/dist/client/components/router-reducer/reducers/navigate-reducer.js",
"role": "evidence",
"startLine": 98
},
{
"endLine": 403,
"path": "apps/frontend/node_modules/next/dist/client/components/app-router.js",
"role": "evidence",
"startLine": 396
}
],
"occurrenceId": "occ_1147b6fa6f91560290ea0748",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "front-0",
"originalCandidates": [
{
"attacker": "Unauthenticated attacker who persuades victim to authenticate using crafted login URL",
"confidence": "high",
"control": "No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation.",
"counterevidence": "HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically.",
"cwe": "CWE-79",
"evidence": "A redirect value javascript:alert(document.domain) reaches router.push unchanged. Layout imports Providers whose AuthProvider alias resolves to src/lib via tsconfig.",
"flow": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.",
"line": 110,
"path": "apps/frontend/src/lib/context/auth-context.tsx",
"remediation": "Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary.",
"severity": "high",
"title": "Unsanitized login redirect executes JavaScript after authentication"
}
],
"source": "local_plugin"
},
"remediation": "Resolve destination against expected origin, require same-origin http(s) URL and canonical internal pathname; reject protocol-relative and non-http schemes. Enforce at AuthProvider boundary.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5"
],
"summary": "No protocol/origin allowlist; Next installed app-router.js:169 builds URL, :95 compares origin, :401 uses location.assign for external navigation. login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication."
},
"ruleId": "xss.login-redirect",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "high",
"rationale": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically."
},
"summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-79"
]
},
"title": "La redirection de connexion permet une XSS DOM",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "login/page.tsx:97 reads redirect query; :165 passes to active AuthProvider; auth-context.tsx:110 calls raw next/navigation router.push after successful authentication. Contre-preuves : HttpOnly token cookies block direct token read but do not prevent script performing authenticated API requests. Production security-header middleware has no CSP. Requires victim login interaction; not tested dynamically."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/controllers/organizations.controller.ts",
"source": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible",
"summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible",
"entrypoint": "apps/backend/src/application/auth/jwt.strategy.ts",
"summary": "Manager authentifi\u00e9 connaissant l\u2019UUID d\u2019une organisation cible. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role."
},
"summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche."
},
"codeEvidence": [
{
"code": " role: user.role,\n organizationId: user.organizationId,\n firstName: user.firstName,\n lastName: user.lastName,\n };\n }\n}",
"endLine": 81,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/auth/jwt.strategy.ts",
"role": "evidence",
"startLine": 75
},
{
"code": "\n // Case-insensitive role comparison\n const userRole = user.role.toLowerCase();\n const requiredRolesLower = requiredRoles.map(r => r.toLowerCase());\n\n return requiredRolesLower.includes(userRole);\n }\n}",
"endLine": 50,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/guards/roles.guard.ts",
"role": "evidence",
"startLine": 43
},
{
"code": " async updateOrganization(\n @Param('id', ParseUUIDPipe) id: string,\n @Body() dto: UpdateOrganizationDto,\n @CurrentUser() user: UserPayload\n ): Promise<OrganizationResponseDto> {\n this.logger.log(`[User: ${user.email}] Updating organization: ${id}`);\n\n const organization = await this.organizationRepository.findById(id);\n if (!organization) {\n throw new NotFoundException(`Organization ${id} not found`);\n }\n\n // Authorization: Managers can only update their own organization\n if (user.role === 'manager' && organization.id !== user.organizationId) {\n throw new ForbiddenException('You can only update your own organization');\n }",
"endLine": 256,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/controllers/organizations.controller.ts",
"role": "root_control",
"startLine": 241
},
{
"code": " if (dto.isActive !== undefined) {\n if (dto.isActive) {\n organization.activate();\n } else {\n organization.deactivate();\n }\n }\n\n // Save updated organization\n const updatedOrg = await this.organizationRepository.save(organization);\n\n this.logger.log(`Organization updated successfully: ${updatedOrg.id}`);\n\n return OrganizationMapper.toDto(updatedOrg);",
"endLine": 304,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/controllers/organizations.controller.ts",
"role": "evidence",
"startLine": 291
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_a99d96667b01f080ce1662eb",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:d66394ca698616dc90984ad1b467a4f1b4b75e295992332f83d852404abea340"
},
"identity": {
"anchor": "un-manager-peut-modifier-une-autre-organisation"
},
"locations": [
{
"endLine": 81,
"path": "apps/backend/src/application/auth/jwt.strategy.ts",
"role": "evidence",
"startLine": 75
},
{
"endLine": 50,
"path": "apps/backend/src/application/guards/roles.guard.ts",
"role": "evidence",
"startLine": 43
},
{
"endLine": 256,
"path": "apps/backend/src/application/controllers/organizations.controller.ts",
"role": "root_control",
"startLine": 241
},
{
"endLine": 304,
"path": "apps/backend/src/application/controllers/organizations.controller.ts",
"role": "evidence",
"startLine": 291
}
],
"occurrenceId": "occ_2bd06c4e81d5cdbd4052ec66",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "org-role-case",
"originalCandidates": [
{
"evidence": "organizations.controller.ts:254 uses lowercase manager; persisted role is uppercase MANAGER",
"title": "Cross-tenant organization update"
}
],
"source": "local_plugin"
},
"remediation": "Refuser tout appel non ADMIN dont la cible diff\u00e8re de l\u2019organisation de la session ; utiliser l\u2019enum de r\u00f4le et appliquer le pr\u00e9dicat dans le service.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "Seul ADMIN peut modifier une autre organisation. PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche."
},
"ruleId": "authorization.organization-role-case",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "high",
"rationale": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche. UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role."
},
"summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-863"
]
},
"title": "Un manager peut modifier une autre organisation",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "PATCH /organizations/:id charge la cible. RolesGuard accepte MANAGER mais le handler ne v\u00e9rifie le tenant que pour manager en minuscules. Le manager peut donc modifier les coordonn\u00e9es, identifiants et statut d\u2019une autre organisation et recevoir sa fiche. Contre-preuves : UUID cible requis ; aucun acc\u00e8s anonyme. Le r\u00f4le reste en majuscules dans JwtStrategy. La comparaison du garde ne normalise pas request.user.role."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"sink": "apps/backend/src/application/gateways/notifications.gateway.ts",
"source": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout",
"summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"reachability": {
"attacker": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout",
"entrypoint": "apps/backend/src/application/gateways/notifications.gateway.ts",
"summary": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover."
},
"summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired."
},
"codeEvidence": [
{
"code": " const payload = await this.jwtService.verifyAsync(token);\n const userId = payload.sub;",
"endLine": 61,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "root_control",
"startLine": 60
},
{
"code": " const payload = await this.jwtService.verifyAsync(token);\n const userId = payload.sub;\n\n // Store socket connection for user\n if (!this.userSockets.has(userId)) {\n this.userSockets.set(userId, new Set());\n }\n this.userSockets.get(userId)!.add(client.id);\n\n // Store user ID in socket data for later use\n client.data.userId = userId;\n client.data.organizationId = payload.organizationId;\n\n // Join user-specific room\n client.join(`user:${userId}`);\n\n this.logger.log(`Client ${client.id} connected for user ${userId}`);\n\n // Send unread count on connection\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n client.emit('unread_count', { count: unreadCount });\n\n // Send recent notifications on connection",
"endLine": 82,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "evidence",
"startLine": 60
},
{
"code": " JwtModule.registerAsync({\n imports: [ConfigModule],\n useFactory: (configService: ConfigService) => ({\n secret: configService.get<string>('JWT_SECRET'),\n signOptions: {\n expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),\n },\n }),",
"endLine": 28,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/notifications/notifications.module.ts",
"role": "evidence",
"startLine": 21
},
{
"code": " const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {\n secret: this.configService.get('JWT_SECRET'),\n });\n\n if (payload.type !== 'refresh') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n if (await this.isRefreshTokenRevoked(refreshToken)) {\n throw new UnauthorizedException('Refresh token has been revoked');\n }\n\n const user = await this.userRepository.findById(payload.sub);\n\n if (!user || !user.isActive) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n const rememberMe = payload.rememberMe === true;\n const tokens = await this.generateTokens(user, rememberMe);",
"endLine": 253,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 234
},
{
"code": " if (payload.type !== 'access') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n // Validate user exists and is active\n const user = await this.authService.validateUser(payload);\n\n if (!user) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n // This object will be attached to request.user\n return {",
"endLine": 72,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e4",
"label": "Source 5",
"path": "apps/backend/src/application/auth/jwt.strategy.ts",
"role": "evidence",
"startLine": 60
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_031c778b7b21254a01a74864",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:d75a150a0eb1c8cfe10e9bf7de156129e43baea0ca69a6053dfaa2a6af99ad52"
},
"identity": {
"anchor": "les-websockets-acceptent-des-sessions-revoquees-ou-desactivees"
},
"locations": [
{
"endLine": 61,
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "root_control",
"startLine": 60
},
{
"endLine": 82,
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "evidence",
"startLine": 60
},
{
"endLine": 28,
"path": "apps/backend/src/application/notifications/notifications.module.ts",
"role": "evidence",
"startLine": 21
},
{
"endLine": 253,
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 234
},
{
"endLine": 72,
"path": "apps/backend/src/application/auth/jwt.strategy.ts",
"role": "evidence",
"startLine": 60
}
],
"occurrenceId": "occ_4fc1a504e8a941dcd377f6a2",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-0",
"originalCandidates": [
{
"attacker": "Holder of an unexpired JWT belonging to a deactivated account or a refresh token revoked through logout",
"confidence": "high",
"counterevidence": "JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover.",
"cwe": "CWE-287",
"evidence": [
{
"lines": "60-84",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"source": "verifyAsync(token); userId=payload.sub; join user room; emit recent_notifications"
},
{
"lines": "21-28",
"path": "apps/backend/src/application/notifications/notifications.module.ts",
"source": "JwtModule uses JWT_SECRET"
},
{
"lines": "234-253,457-475",
"path": "apps/backend/src/application/auth/auth.service.ts",
"source": "Refresh tokens signed with same JwtService; HTTP refresh checks revoked token and active account"
},
{
"lines": "60-72",
"path": "apps/backend/src/application/auth/jwt.strategy.ts",
"source": "HTTP strategy checks type===access and active user"
}
],
"flow": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.",
"invariant": "Notifications must require a current active account and access-token authentication",
"remediation": "Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy.",
"severity": "medium",
"title": "WebSocket authentication accepts revoked refresh tokens and inactive users"
}
],
"source": "local_plugin"
},
"remediation": "Require access payload type, validate live user, and enforce socket expiry/account revocation; use a shared authentication policy.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"summary": "Notifications must require a current active account and access-token authentication Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired."
},
"ruleId": "authentication.websocket-session-validation",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover."
},
"summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-287"
]
},
"title": "Les WebSockets acceptent des sessions r\u00e9voqu\u00e9es ou d\u00e9sactiv\u00e9es",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Connect /notifications with revoked refresh token in handshake.auth.token. Gateway only verifies signature and expiry, joins user room and discloses recent notification messages/metadata. Deactivation/deletion is never checked and open sockets are not expired. Contre-preuves : JWT signature and expiry are verified; no arbitrary token forgery. HTTP strategy is stronger but gateway never calls it. Scope is notification data, not general REST takeover."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"sink": "apps/backend/src/application/gateways/notifications.gateway.ts",
"source": "Any authenticated WebSocket user",
"summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"reachability": {
"attacker": "Any authenticated WebSocket user",
"entrypoint": "apps/backend/src/application/gateways/notifications.gateway.ts",
"summary": "Any authenticated WebSocket user. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty."
},
"summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)."
},
"codeEvidence": [
{
"code": " ) {\n try {\n const userId = client.data.userId;\n await this.notificationService.markAsRead(data.notificationId);\n\n // Send updated unread count\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n this.emitToUser(userId, 'unread_count', { count: unreadCount });",
"endLine": 124,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "root_control",
"startLine": 117
},
{
"code": " */\n @SubscribeMessage('mark_as_read')\n async handleMarkAsRead(\n @ConnectedSocket() client: Socket,\n @MessageBody() data: { notificationId: string }\n ) {\n try {\n const userId = client.data.userId;\n await this.notificationService.markAsRead(data.notificationId);\n\n // Send updated unread count\n const unreadCount = await this.notificationService.getUnreadCount(userId);\n this.emitToUser(userId, 'unread_count', { count: unreadCount });",
"endLine": 124,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "evidence",
"startLine": 112
},
{
"code": " /**\n * Delete notification\n */",
"endLine": 127,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/services/notification.service.ts",
"role": "evidence",
"startLine": 125
},
{
"code": " async markAsRead(id: string): Promise<void> {\n await this.ormRepository.update(id, {\n read: true,\n read_at: new Date(),\n });\n }",
"endLine": 158,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts",
"role": "evidence",
"startLine": 153
},
{
"code": " update(target, criteria, partialEntity) {\n // if user passed empty criteria or empty list of criterias, then throw an error\n if (OrmUtils_1.OrmUtils.isCriteriaNullOrEmpty(criteria)) {\n return Promise.reject(new error_1.TypeORMError(`Empty criteria(s) are not allowed for the update method.`));\n }\n if (OrmUtils_1.OrmUtils.isPrimitiveCriteria(criteria)) {\n return this.createQueryBuilder()\n .update(target)\n .set(partialEntity)\n .whereInIds(criteria)\n .execute();\n }\n else {\n return this.createQueryBuilder()\n .update(target)\n .set(partialEntity)\n .where(criteria)\n .execute();\n }\n }",
"endLine": 365,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e4",
"label": "Source 5",
"path": "apps/backend/node_modules/typeorm/entity-manager/EntityManager.js",
"role": "evidence",
"startLine": 346
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_7f33ab35ec82433af164cb40",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:d713a1fce8cc4f3a1ec7860a727d88bddfe2b221baffe79742d5361c68c1324c"
},
"identity": {
"anchor": "un-membre-peut-marquer-toutes-les-notifications-comme-lues"
},
"locations": [
{
"endLine": 124,
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "root_control",
"startLine": 117
},
{
"endLine": 124,
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"role": "evidence",
"startLine": 112
},
{
"endLine": 127,
"path": "apps/backend/src/application/services/notification.service.ts",
"role": "evidence",
"startLine": 125
},
{
"endLine": 158,
"path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts",
"role": "evidence",
"startLine": 153
},
{
"endLine": 365,
"path": "apps/backend/node_modules/typeorm/entity-manager/EntityManager.js",
"role": "evidence",
"startLine": 346
}
],
"occurrenceId": "occ_971e94ca8f3ed2e8bd6cf5ff",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-1",
"originalCandidates": [
{
"attacker": "Any authenticated WebSocket user",
"confidence": "high",
"counterevidence": "REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty.",
"cwe": "CWE-639",
"evidence": [
{
"lines": "112-124",
"path": "apps/backend/src/application/gateways/notifications.gateway.ts",
"source": "@MessageBody() data: { notificationId: string }; markAsRead(data.notificationId)"
},
{
"lines": "125-127",
"path": "apps/backend/src/application/services/notification.service.ts",
"source": "notificationRepository.markAsRead(id)"
},
{
"lines": "153-158",
"path": "apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-notification.repository.ts",
"source": "ormRepository.update(id, {read:true,read_at:new Date()})"
}
],
"flow": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).",
"invariant": "Only the notification recipient may mark their own notification as read",
"remediation": "Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods.",
"severity": "medium",
"title": "WebSocket notification update permits cross-tenant bulk marking as read"
}
],
"source": "local_plugin"
},
"remediation": "Use a validated UUID message DTO and an update predicate containing id AND authenticated user_id; never pass caller-selected criteria into ORM methods.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"summary": "Only the notification recipient may mark their own notification as read Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria)."
},
"ruleId": "authorization.notification-update",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty."
},
"summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria).",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-639"
]
},
"title": "Un membre peut marquer toutes les notifications comme lues",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Send mark_as_read with another user notification UUID. Additionally send {notificationId:{read:false}}: inline TS type does not validate wire input; object reaches TypeORM update criteria and updates every unread notification across organizations. Installed EntityManager.js:346-364 confirms nonprimitive criteria passed to .where(criteria). Contre-preuves : REST notifications.controller.ts checks userId ownership. Gateway omits that check; no validated message DTO. Installed TypeORM rejects empty criteria, but {read:false} is nonempty."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"sink": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "Booking creator or another organization member reading organization/all",
"summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"reachability": {
"attacker": "Booking creator or another organization member reading organization/all",
"entrypoint": "apps/backend/src/application/services/csv-booking.service.ts",
"summary": "Booking creator or another organization member reading organization/all. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision."
},
"summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier."
},
"codeEvidence": [
{
"code": " status: booking.status,\n documents: booking.documents.map(this.toDocumentDto),\n confirmationToken: booking.confirmationToken,\n requestedAt: booking.requestedAt,\n respondedAt: booking.respondedAt || null,",
"endLine": 1612,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "root_control",
"startLine": 1608
},
{
"code": " return this.toResponseDto(savedBooking);",
"endLine": 244,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 244
},
{
"code": " @Public()\n @Get('accept/:token')\n @ApiOperation({\n summary: 'Accept booking request (public)',\n description:\n 'Public endpoint for carriers to accept a booking via email link. Updates booking status and notifies the user.',\n })\n @ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })\n @ApiResponse({\n status: 200,\n description: 'Booking accepted successfully.',\n })\n @ApiResponse({ status: 404, description: 'Booking not found or invalid token' })\n @ApiResponse({\n status: 400,\n description: 'Booking cannot be accepted (invalid status or expired)',\n })\n async acceptBooking(@Param('token') token: string) {\n // Accept the booking\n const booking = await this.csvBookingService.acceptBooking(token);",
"endLine": 47,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts",
"role": "evidence",
"startLine": 28
},
{
"code": " async acceptBooking(token: string): Promise<CsvBookingResponseDto> {\n this.logger.log(`Accepting booking with token: ${token}`);\n\n const booking = await this.csvBookingRepository.findByToken(token);\n\n if (!booking) {\n throw new NotFoundException('Booking not found');\n }\n\n // Get ORM entity for bookingNumber\n const ormBooking = await this.csvBookingRepository['repository'].findOne({\n where: { confirmationToken: token },\n });\n\n // Accept the booking (domain logic validates status)\n booking.accept();\n\n // Apply the flat per-booking service fee (forfait par booking) from the org's plan\n const bookingFeeEur = await this.resolveBookingFeeEur(booking.organizationId);\n booking.applyBookingFee(bookingFeeEur);\n this.logger.log(\n `Booking fee applied: ${bookingFeeEur > 0 ? `${bookingFeeEur}\u20ac (flat)` : 'none (custom)'} on booking ${booking.id}`\n );",
"endLine": 908,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 886
},
{
"code": " * This is a simplified booking workflow for CSV-based rates where the user\n * selects a rate and sends a booking request to the carrier with documents.\n *\n * Business Rules:\n * - Booking can only be accepted/rejected when status is PENDING\n * - Once accepted/rejected, status cannot be changed\n * - Booking expires after 7 days if not responded to\n * - At least one document is required for booking creation\n * - Confirmation token is used for email accept/reject links\n * - Only carrier can accept/reject via email link\n * - User can cancel pending bookings",
"endLine": 65,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e4",
"label": "Source 5",
"path": "apps/backend/src/domain/entities/csv-booking.entity.ts",
"role": "evidence",
"startLine": 55
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_5b73c5668d032d8ea730de12",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:c620fcd5111eeae81ce76e54c934caa7d3950b6ea580def72df49a929ad154b5"
},
"identity": {
"anchor": "le-client-recoit-le-jeton-de-reponse-du-transporteur"
},
"locations": [
{
"endLine": 1612,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "root_control",
"startLine": 1608
},
{
"endLine": 244,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 244
},
{
"endLine": 47,
"path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts",
"role": "evidence",
"startLine": 28
},
{
"endLine": 908,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 886
},
{
"endLine": 65,
"path": "apps/backend/src/domain/entities/csv-booking.entity.ts",
"role": "evidence",
"startLine": 55
}
],
"occurrenceId": "occ_5145afbcb4a011920eb68e70",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-2",
"originalCandidates": [
{
"attacker": "Booking creator or another organization member reading organization/all",
"confidence": "high",
"counterevidence": "Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision.",
"cwe": "CWE-863",
"evidence": [
{
"lines": "244,1610",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "createBooking returns toResponseDto; DTO includes confirmationToken"
},
{
"lines": "28-47",
"path": "apps/backend/src/application/controllers/csv-booking-actions.controller.ts",
"source": "@Public() GET accept/:token forwards to acceptBooking"
},
{
"lines": "886-914",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "findByToken(token); booking.accept(); repository.update(booking)"
},
{
"lines": "55-65",
"path": "apps/backend/src/domain/entities/csv-booking.entity.ts",
"source": "Only carrier can accept/reject via email link"
}
],
"flow": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.",
"invariant": "Only the carrier receiving the email credential may accept or reject a booking",
"remediation": "Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens.",
"severity": "high",
"title": "CSV booking responses expose the carrier accept/reject credential"
}
],
"source": "local_plugin"
},
"remediation": "Remove action credentials from all normal booking responses and use separate carrier-only scoped tokens. Require carrier-side authenticated confirmation and rotate exposed tokens.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"summary": "Only the carrier receiving the email credential may accept or reject a booking Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier."
},
"ruleId": "authorization.carrier-token-disclosure",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision."
},
"summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-863"
]
},
"title": "Le client re\u00e7oit le jeton de r\u00e9ponse du transporteur",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Create booking or list own booking, read confirmationToken; after it is PENDING call public accept/reject endpoint. Status is recorded as carrier accepted/rejected and notifications/emails are sent without contacting carrier. Contre-preuves : Domain rejects unpaid, expired and already resolved bookings, so this does not bypass payment. Password protects document downloads but is not required for carrier decision."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "Active VIEWER account including account downgraded from USER",
"summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Active VIEWER account including account downgraded from USER",
"entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"summary": "Active VIEWER account including account downgraded from USER. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source."
},
"summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings."
},
"codeEvidence": [
{
"code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))",
"endLine": 88,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 86
},
{
"code": " VIEWER = 'VIEWER', // Read-only access",
"endLine": 19,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/domain/entities/user.entity.ts",
"role": "evidence",
"startLine": 19
},
{
"code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))",
"endLine": 88,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 86
},
{
"code": " this.logger.log(`Creating CSV booking for user ${userId}`);\n\n // Validate minimum document requirement\n if (!files || files.length === 0) {\n throw new BadRequestException('At least one document is required');\n }\n\n // Generate unique confirmation token and booking number\n const confirmationToken = uuidv4();\n const bookingId = uuidv4();\n const bookingNumber = this.generateBookingNumber();\n const documentPassword = this.deriveDocumentPassword(bookingId);\n\n // Hash the password for storage\n const passwordHash = await argon2.hash(documentPassword);\n\n // Upload documents to S3\n const documents = await this.uploadDocuments(files, bookingId);\n\n // Flat per-booking service fee (forfait par booking) based on the org's plan.\n // A fee <= 0 (e.g. Platinium \"sur mesure\") means no automatic charge: the\n // booking skips the payment gate and the carrier is notified immediately.\n const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);",
"endLine": 168,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 146
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_1876a06cabe3545c1be17f50",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:3f6bc3941b7e4e27d6e0a7e3db05eb4e8168bf61484402abc3b3eecbd5d2f4f4"
},
"identity": {
"anchor": "viewer-peut-creer-et-modifier-des-reservations"
},
"locations": [
{
"endLine": 88,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 86
},
{
"endLine": 19,
"path": "apps/backend/src/domain/entities/user.entity.ts",
"role": "evidence",
"startLine": 19
},
{
"endLine": 88,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 86
},
{
"endLine": 168,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 146
}
],
"occurrenceId": "occ_6f0576490bbab107cd67cfe5",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-4",
"originalCandidates": [
{
"attacker": "Active VIEWER account including account downgraded from USER",
"confidence": "high",
"counterevidence": "Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source.",
"cwe": "CWE-862",
"evidence": [
{
"lines": "19,188-193",
"path": "apps/backend/src/domain/entities/user.entity.ts",
"source": "VIEWER read-only; canCreateBookings excludes VIEWER"
},
{
"lines": "86-88,150-214",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "create route authenticates but never checks role"
},
{
"lines": "146-244",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "createBooking saves supplied booking for caller"
}
],
"flow": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.",
"invariant": "VIEWER role is read-only and cannot create bookings",
"remediation": "Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths.",
"severity": "medium",
"title": "Read-only VIEWER accounts can create and mutate CSV bookings"
}
],
"source": "local_plugin"
},
"remediation": "Apply role policy to every booking mutation (ADMIN/MANAGER/USER), while preserving VIEWER read paths.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "VIEWER role is read-only and cannot create bookings VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings."
},
"ruleId": "authorization.viewer-booking-mutations",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source."
},
"summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-862"
]
},
"title": "VIEWER peut cr\u00e9er et modifier des r\u00e9servations",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "VIEWER submits POST /csv-bookings with valid multipart documents and booking DTO; controller checks authentication and quota but not role, and service persists booking. Owner mutation routes likewise permit VIEWER to edit, delete, pay or cancel their existing bookings. Contre-preuves : Identity and ownership checks prevent arbitrary other-user mutation; subscription/shipment gates still apply. canCreateBookings is never invoked in repository source."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/controllers/users.controller.ts",
"source": "Operator or attacker able to read application logs but not authorized to authenticate as users",
"summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Operator or attacker able to read application logs but not authorized to authenticate as users",
"entrypoint": "apps/backend/src/application/controllers/users.controller.ts",
"summary": "Operator or attacker able to read application logs but not authorized to authenticate as users. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented."
},
"summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee."
},
"codeEvidence": [
{
"code": " // TODO: Send invitation email with temporary password\n this.logger.warn(\n `TODO: Send invitation email to ${dto.email} with temp password: ${tempPassword}`\n );",
"endLine": 166,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "root_control",
"startLine": 163
},
{
"code": " const tempPassword = dto.password || this.generateTemporaryPassword();\n\n // Hash password with Argon2id\n const passwordHash = await argon2.hash(tempPassword, {\n type: argon2.argon2id,\n memoryCost: 65536, // 64 MB\n timeCost: 3,\n parallelism: 4,\n });\n\n // Map DTO role to Domain role\n const domainRole = dto.role as unknown as DomainUserRole;\n\n // Create user entity\n const newUser = User.create({\n id: uuidv4(),\n organizationId: dto.organizationId,\n email: dto.email,\n passwordHash,\n firstName: dto.firstName,\n lastName: dto.lastName,\n role: domainRole,\n });",
"endLine": 156,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 134
},
{
"code": " const invitationLink = `${frontendUrl}/register?token=${invitation.token}`;\n\n this.logger.log(`[INVITATION] Frontend URL: ${frontendUrl}`);\n this.logger.log(`[INVITATION] Invitation link: ${invitationLink}`);",
"endLine": 181,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/services/invitation.service.ts",
"role": "evidence",
"startLine": 178
},
{
"code": " level: isDev ? 'debug' : 'info',\n // Redact sensitive fields from logs\n redact: {\n paths: [\n 'req.headers.authorization',\n 'req.headers[\"x-api-key\"]',\n 'req.body.password',\n 'req.body.currentPassword',\n 'req.body.newPassword',\n ],\n censor: '[REDACTED]',\n },\n },",
"endLine": 135,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/app.module.ts",
"role": "evidence",
"startLine": 123
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_13367c121ce207647f4a6533",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:93fc9bffa9aee6f0eca0a51e8898c45597be65756c0307296df81134a8e7a466"
},
"identity": {
"anchor": "les-logs-contiennent-mots-de-passe-et-invitations"
},
"locations": [
{
"endLine": 166,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "root_control",
"startLine": 163
},
{
"endLine": 156,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 134
},
{
"endLine": 181,
"path": "apps/backend/src/application/services/invitation.service.ts",
"role": "evidence",
"startLine": 178
},
{
"endLine": 135,
"path": "apps/backend/src/app.module.ts",
"role": "evidence",
"startLine": 123
}
],
"occurrenceId": "occ_5494e6769cd3425850eb7778",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-5",
"originalCandidates": [
{
"attacker": "Operator or attacker able to read application logs but not authorized to authenticate as users",
"confidence": "high",
"counterevidence": "Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented.",
"cwe": "CWE-532",
"evidence": [
{
"lines": "134-165",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"source": "tempPassword=dto.password || generated; logger.warn interpolates actual password"
},
{
"lines": "178-181",
"path": "apps/backend/src/application/services/invitation.service.ts",
"source": "logger.log interpolates invitationLink containing active invitation token"
},
{
"lines": "123-135",
"path": "apps/backend/src/app.module.ts",
"source": "Pino redact paths cover structured request password fields, not interpolated message secrets"
}
],
"flow": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.",
"invariant": "Authentication secrets must not be exposed to log readers",
"remediation": "Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials.",
"severity": "high",
"title": "Passwords and invitation bearer credentials are written to application logs"
}
],
"source": "local_plugin"
},
"remediation": "Delete secret-bearing logger messages, redact cookie/token fields, use expiring one-use invitation activation instead of logging generated passwords, and rotate any exposed credentials.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "Authentication secrets must not be exposed to log readers Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee."
},
"ruleId": "credential-exposure.application-logs",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented."
},
"summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-532"
]
},
"title": "Les logs contiennent mots de passe et invitations",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Creating any user logs its plaintext password together with email after saving its hash; log readers can authenticate as that user, including ADMIN created by another administrator. Invitation creation logs active registration token usable with publicly returned invitation email to register as invitee. Contre-preuves : Requires log access; passwords are hashed in database. Structured Pino redaction does not redact secrets embedded into message strings. No forced temporary-password change implemented."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/auth/auth.service.ts",
"source": "Attacker holding a victim refresh token before password recovery",
"summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Attacker holding a victim refresh token before password recovery",
"entrypoint": "apps/backend/src/application/auth/auth.service.ts",
"summary": "Attacker holding a victim refresh token before password recovery. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed."
},
"summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access."
},
"codeEvidence": [
{
"code": " // Update password (mutates in place)\n user.updatePassword(passwordHash);\n await this.userRepository.save(user);\n\n // Mark token as used\n await this.passwordResetTokenRepository.update({ id: resetToken.id }, { usedAt: new Date() });\n",
"endLine": 392,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "root_control",
"startLine": 386
},
{
"code": " async resetPassword(token: string, newPassword: string): Promise<void> {\n const resetToken = await this.passwordResetTokenRepository.findOne({\n where: { token: this.hashResetToken(token) },\n });\n\n if (!resetToken) {\n throw new BadRequestException('Token de r\u00e9initialisation invalide ou expir\u00e9');\n }\n\n if (resetToken.usedAt) {\n throw new BadRequestException('Ce lien de r\u00e9initialisation a d\u00e9j\u00e0 \u00e9t\u00e9 utilis\u00e9');\n }\n\n if (resetToken.expiresAt < new Date()) {\n throw new BadRequestException(\n 'Le lien de r\u00e9initialisation a expir\u00e9. Veuillez en demander un nouveau.'\n );\n }\n\n const user = await this.userRepository.findById(resetToken.userId);\n\n if (!user || !user.isActive) {\n throw new NotFoundException('Utilisateur introuvable');",
"endLine": 376,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 354
},
{
"code": " const payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {\n secret: this.configService.get('JWT_SECRET'),\n });\n\n if (payload.type !== 'refresh') {\n throw new UnauthorizedException('Invalid token type');\n }\n\n if (await this.isRefreshTokenRevoked(refreshToken)) {\n throw new UnauthorizedException('Refresh token has been revoked');\n }\n\n const user = await this.userRepository.findById(payload.sub);\n\n if (!user || !user.isActive) {\n throw new UnauthorizedException('User not found or inactive');\n }\n\n const rememberMe = payload.rememberMe === true;\n const tokens = await this.generateTokens(user, rememberMe);",
"endLine": 253,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 234
},
{
"code": " updatePassword(newPasswordHash: string): void {\n this.props.passwordHash = newPasswordHash;\n this.props.updatedAt = new Date();\n }",
"endLine": 199,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/domain/entities/user.entity.ts",
"role": "evidence",
"startLine": 196
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_3ea856b16338984d28e2c344",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:dba4deafa83f815d5f54f3e7f2951e3ce9194ed24b09f77f801c05835bf5b693"
},
"identity": {
"anchor": "le-changement-de-mot-de-passe-conserve-les-anciennes-sessions"
},
"locations": [
{
"endLine": 392,
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "root_control",
"startLine": 386
},
{
"endLine": 376,
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 354
},
{
"endLine": 253,
"path": "apps/backend/src/application/auth/auth.service.ts",
"role": "evidence",
"startLine": 234
},
{
"endLine": 199,
"path": "apps/backend/src/domain/entities/user.entity.ts",
"role": "evidence",
"startLine": 196
}
],
"occurrenceId": "occ_b3baf623592ccfdf73fc5ecb",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-6",
"originalCandidates": [
{
"attacker": "Attacker holding a victim refresh token before password recovery",
"confidence": "high",
"counterevidence": "Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed.",
"cwe": "CWE-613",
"evidence": [
{
"lines": "354-394",
"path": "apps/backend/src/application/auth/auth.service.ts",
"source": "resetPassword updates passwordHash then marks reset token used; no session invalidation"
},
{
"lines": "234-253",
"path": "apps/backend/src/application/auth/auth.service.ts",
"source": "refresh only verifies signature/type, per-token logout blacklist, active user"
},
{
"lines": "196-199",
"path": "apps/backend/src/domain/entities/user.entity.ts",
"source": "updatePassword only changes hash and updatedAt"
}
],
"flow": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.",
"invariant": "Recovering a compromised account must invalidate pre-reset authentication sessions",
"remediation": "Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change.",
"severity": "medium",
"title": "Password recovery does not invalidate existing refresh sessions"
}
],
"source": "local_plugin"
},
"remediation": "Store session/token version or passwordChangedAt and check it for every refresh/access token; increment/revoke all sessions on password recovery and offer revocation on ordinary password change.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "Recovering a compromised account must invalidate pre-reset authentication sessions Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access."
},
"ruleId": "session-invalidation.password-reset",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed."
},
"summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-613"
]
},
"title": "Le changement de mot de passe conserve les anciennes sessions",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Victim successfully resets password. Attacker presents previously stolen unexpired refresh token; refreshAccessToken sees active user and no logout blacklist entry and mints fresh tokens, preserving takeover beyond recovery. Repeating refresh extends access. Contre-preuves : Reset tokens are random, hashed, expiring and checked for use; issue concerns already stolen refresh tokens. Password change alone is not otherwise claimed to revoke sessions in UI reviewed."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5",
"e6"
],
"sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "Any authenticated account, including newly registered free-plan user",
"summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5",
"e6"
],
"reachability": {
"attacker": "Any authenticated account, including newly registered free-plan user",
"entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"summary": "Any authenticated account, including newly registered free-plan user. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test."
},
"summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory."
},
"codeEvidence": [
{
"code": " @Post()\n @ApiBearerAuth()\n @UseInterceptors(FilesInterceptor('documents', 10))",
"endLine": 88,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 86
},
{
"code": " @UseInterceptors(FilesInterceptor('documents', 10))",
"endLine": 88,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 88
},
{
"code": "@Module({\n imports: [\n TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),\n ConfigModule,\n NotificationsModule,\n EmailModule,\n StorageModule,\n SubscriptionsModule,\n StripeModule,\n ],",
"endLine": 37,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts",
"role": "evidence",
"startLine": 28
},
{
"code": " /** Non-httpOnly flag the frontend reads to know a session exists (contains no token) */\n session: 'xpeditis_session',\n} as const;\n\nexport function authCookieOptions(options?: { maxAgeMs?: number; httpOnly?: boolean }): {\n httpOnly: boolean;\n secure: boolean;\n sameSite: 'lax' | 'strict' | 'none';\n path: string;\n domain?: string;\n maxAge?: number;\n} {\n // SameSite must be 'none' when the frontend and the API live on different\n // sites (cross-origin), otherwise the browser drops the auth cookies set in\n // the cross-site login XHR response. 'none' REQUIRES Secure (HTTPS).\n // Configurable via COOKIE_SAMESITE; defaults to 'lax' for same-site setups.",
"endLine": 194,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/infrastructure/security/security.config.ts",
"role": "evidence",
"startLine": 179
},
{
"code": "function Multer (options) {\n if (options.storage) {\n this.storage = options.storage\n } else if (options.dest) {\n this.storage = diskStorage({ destination: options.dest })\n } else {\n this.storage = memoryStorage()\n }\n\n this.limits = options.limits\n this.preservePath = options.preservePath\n this.fileFilter = options.fileFilter || allowAll\n}",
"endLine": 23,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e4",
"label": "Source 5",
"path": "apps/backend/node_modules/multer/index.js",
"role": "evidence",
"startLine": 11
},
{
"code": "function MemoryStorage (opts) {}\n\nMemoryStorage.prototype._handleFile = function _handleFile (req, file, cb) {\n file.stream.pipe(concat({ encoding: 'buffer' }, function (data) {\n cb(null, {\n buffer: data,\n size: data.length\n })\n }))\n}",
"endLine": 12,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e5",
"label": "Source 6",
"path": "apps/backend/node_modules/multer/storage/memory.js",
"role": "evidence",
"startLine": 3
},
{
"code": " const limits = cfg.limits;\n const fieldSizeLimit = (limits && typeof limits.fieldSize === 'number'\n ? limits.fieldSize\n : 1 * 1024 * 1024);\n const fileSizeLimit = (limits && typeof limits.fileSize === 'number'\n ? limits.fileSize\n : Infinity);",
"endLine": 256,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e6",
"label": "Source 7",
"path": "apps/backend/node_modules/busboy/lib/types/multipart.js",
"role": "evidence",
"startLine": 250
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_bf9e4938067e4e0ec02624ca",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:4cbc2135cb631fb2ed5d2c3639da04f13558b2fd303e0876eaeac63f36e2b642"
},
"identity": {
"anchor": "les-televersements-ne-bornent-pas-la-memoire-utilisee"
},
"locations": [
{
"endLine": 88,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 86
},
{
"endLine": 88,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 88
},
{
"endLine": 37,
"path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts",
"role": "evidence",
"startLine": 28
},
{
"endLine": 194,
"path": "apps/backend/src/infrastructure/security/security.config.ts",
"role": "evidence",
"startLine": 179
},
{
"endLine": 23,
"path": "apps/backend/node_modules/multer/index.js",
"role": "evidence",
"startLine": 11
},
{
"endLine": 12,
"path": "apps/backend/node_modules/multer/storage/memory.js",
"role": "evidence",
"startLine": 3
},
{
"endLine": 256,
"path": "apps/backend/node_modules/busboy/lib/types/multipart.js",
"role": "evidence",
"startLine": 250
}
],
"occurrenceId": "occ_0e8b50e3868ae0135d1f03b2",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-7",
"originalCandidates": [
{
"attacker": "Any authenticated account, including newly registered free-plan user",
"confidence": "high",
"counterevidence": "Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test.",
"cwe": "CWE-400",
"evidence": [
{
"lines": "88,690,746",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "FilesInterceptor has count only, no local limits"
},
{
"lines": "28-37",
"path": "apps/backend/src/application/csv-bookings/csv-bookings.module.ts",
"source": "No MulterModule defaults supplied"
},
{
"lines": "179-194",
"path": "apps/backend/src/infrastructure/security/security.config.ts",
"source": "fileUploadConfig declares maxFileSize but is not wired to these interceptors"
}
],
"flow": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.",
"invariant": "Single upload requests must have bounded resource use before buffering",
"remediation": "Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage.",
"severity": "medium",
"title": "CSV document uploads buffer files without a size limit"
}
],
"source": "local_plugin"
},
"remediation": "Configure limits.fileSize, files, fields and parts on all upload interceptors; enforce ingress total-body limit and stream large uploads to storage.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5",
"e6"
],
"summary": "Single upload requests must have bounded resource use before buffering POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory."
},
"ruleId": "resource-exhaustion.multipart-memory",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test."
},
"summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-400"
]
},
"title": "Les t\u00e9l\u00e9versements ne bornent pas la m\u00e9moire utilis\u00e9e",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3",
"e4",
"e5",
"e6"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "POST a large multipart document to create/add/replace route. Installed Nest interceptor invokes Multer before controller quotas/ownership; Multer defaults to memoryStorage and Busboy defaults fileSizeLimit to Infinity. Whole file is buffered, potentially exhausting Node memory. Contre-preuves : Authentication/global request rate limit apply, but do not bound per-request bytes. External proxy may cap requests; no verified production proxy cap. Findings are source-level; did not execute load test."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0"
],
"sink": "docker/docker-compose.full.yml",
"source": "Anyone who obtains repository/configuration content",
"summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment."
},
"evidenceRefs": [
"e0"
],
"reachability": {
"attacker": "Anyone who obtains repository/configuration content",
"entrypoint": "docker/docker-compose.full.yml",
"summary": "Anyone who obtains repository/configuration content. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential."
},
"summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment."
},
"codeEvidence": [
{
"code": " SMTP_PASS: [REDACTED]",
"endLine": 137,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "docker/docker-compose.full.yml",
"role": "root_control",
"startLine": 137
}
],
"confidence": {
"level": "medium",
"rationale": "Tra\u00e7age statique du code courant. Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_190e783e3fd6c0427523f25a",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:f06b23146f25cb11d62eb9ce5cfef13eb6c2d9acc51c88fd01e89e2d53707d54"
},
"identity": {
"anchor": "une-cle-smtp-figure-dans-un-fichier-suivi"
},
"locations": [
{
"endLine": 137,
"path": "docker/docker-compose.full.yml",
"role": "root_control",
"startLine": 137
}
],
"occurrenceId": "occ_d4860f2f9683cb292b0ca32d",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "front-1",
"originalCandidates": [
{
"attacker": "Anyone who obtains repository/configuration content",
"confidence": "medium",
"control": "Credential is inline rather than secret reference.",
"counterevidence": "Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential.",
"cwe": "CWE-798",
"evidence": "SMTP_PASS: [REDACTED]. Parent prior auditor identified provider-shaped value; current review kept output redacted.",
"flow": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.",
"line": 137,
"path": "docker/docker-compose.full.yml",
"remediation": "Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret.",
"severity": "high",
"title": "Provider SMTP credential embedded in tracked development compose file"
}
],
"source": "local_plugin"
},
"remediation": "Revoke/rotate provider credential, remove literal from current tracked configuration and source it through secret injection; assess distribution without exposing secret.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0"
],
"summary": "Credential is inline rather than secret reference. Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment."
},
"ruleId": "hardcoded-credential.smtp",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "Format fournisseur confirm\u00e9, mais validit\u00e9 non test\u00e9e. Un lecteur du d\u00e9p\u00f4t peut obtenir la cl\u00e9 ; usage abusif possible si elle est toujours active. Valeur masqu\u00e9e."
},
"summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-798"
]
},
"title": "Une cl\u00e9 SMTP figure dans un fichier suivi",
"validation": {
"evidenceRefs": [
"e0"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Literal SMTP_PASS alongside smtp-relay.brevo.com and concrete SMTP_USER injected directly into backend container environment. Contre-preuves : Credential validity and provider privileges were not tested; this is a development stack, but uses external provider identity rather than documented dummy credential."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/services/subscription.service.ts",
"source": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN",
"summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN",
"entrypoint": "apps/backend/src/application/services/subscription.service.ts",
"summary": "Manager d\u2019une organisation payante ayant au moins deux licences actives non ADMIN. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe."
},
"summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200."
},
"codeEvidence": [
{
"code": " }\n\n // Downgrade to FREE plan - count only non-ADMIN licenses\n const canceledSubscription = subscription\n .updatePlan(\n SubscriptionPlan.bronze(),\n await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(subscription.id)\n )\n .updateStatus(SubscriptionStatus.canceled());\n\n await this.subscriptionRepository.save(canceledSubscription);\n",
"endLine": 619,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/services/subscription.service.ts",
"role": "root_control",
"startLine": 608
},
{
"code": " if (!newPlan.canAccommodateUsers(currentUserCount)) {\n throw new InvalidSubscriptionDowngradeException(\n this.props.plan.value,\n newPlan.value,\n currentUserCount,\n newPlan.maxLicenses\n );\n }",
"endLine": 269,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/domain/entities/subscription.entity.ts",
"role": "root_control",
"startLine": 262
},
{
"code": " maxLicenses: 1,\n monthlyPriceEur: 0,\n yearlyPriceEur: 0,\n maxShipmentsPerYear: 5,\n bookingFeeEur: 15,\n statusBadge: 'none',",
"endLine": 55,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/domain/value-objects/subscription-plan.vo.ts",
"role": "evidence",
"startLine": 50
},
{
"code": " this.logger.error('Webhook processing failed', error);\n return { received: false };\n }",
"endLine": 281,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/controllers/subscriptions.controller.ts",
"role": "root_control",
"startLine": 279
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_4450c37b1177da8cc92d9bbf",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:c957854b5764d83e3df0b07631a0185999a16797f862b0352f241896e37fa19e"
},
"identity": {
"anchor": "la-resiliation-peut-conserver-les-avantages-payants"
},
"locations": [
{
"endLine": 619,
"path": "apps/backend/src/application/services/subscription.service.ts",
"role": "root_control",
"startLine": 608
},
{
"endLine": 269,
"path": "apps/backend/src/domain/entities/subscription.entity.ts",
"role": "evidence",
"startLine": 262
},
{
"endLine": 55,
"path": "apps/backend/src/domain/value-objects/subscription-plan.vo.ts",
"role": "evidence",
"startLine": 50
},
{
"endLine": 281,
"path": "apps/backend/src/application/controllers/subscriptions.controller.ts",
"role": "evidence",
"startLine": 279
}
],
"occurrenceId": "occ_660d06b4ca749441dfe7cc13",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "subscription-cancellation",
"originalCandidates": [
{
"evidence": "handleSubscriptionDeleted calls updatePlan before saving canceled status, updatePlan throws when users exceed limit",
"title": "Cancellation cannot downgrade when active licenses exceed Bronze cap"
}
],
"source": "local_plugin"
},
"remediation": "Persister la r\u00e9siliation ind\u00e9pendamment des limites de licences, retirer les droits effectifs puis r\u00e9soudre le surnombre. Ne pas acquitter une erreur de traitement comme un succ\u00e8s.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "Une r\u00e9siliation doit retirer les droits m\u00eame si le compte d\u00e9passe la capacit\u00e9 gratuite. customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200."
},
"ruleId": "business-logic.subscription-cancellation",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "medium",
"rationale": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200. \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe."
},
"summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-841"
]
},
"title": "La r\u00e9siliation peut conserver les avantages payants",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "customer.subscription.deleted appelle updatePlan(BRONZE, nombreDeLicences) avant updateStatus(CANCELED). Bronze accepte une licence ; avec deux utilisateurs updatePlan l\u00e8ve une exception, donc aucun save ne retire l\u2019offre. Le contr\u00f4leur acquitte n\u00e9anmoins avec HTTP 200. Contre-preuves : \u00c9v\u00e9nement Stripe sign\u00e9 et abonnement payant pr\u00e9existant n\u00e9cessaires. La signature est v\u00e9rifi\u00e9e ; l\u2019attaque exploite les transitions locales, pas une falsification Stripe."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"sink": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "Authenticated USER or VIEWER in organization with other users bookings",
"summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately."
},
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"reachability": {
"attacker": "Authenticated USER or VIEWER in organization with other users bookings",
"entrypoint": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"summary": "Authenticated USER or VIEWER in organization with other users bookings. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads."
},
"summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately."
},
"codeEvidence": [
{
"code": " @Get('organization/all')\n @UseGuards(JwtAuthGuard)\n @ApiBearerAuth()\n @ApiOperation({\n summary: 'Get organization bookings',\n description:\n \"Retrieve all bookings for the user's organization with pagination. For managers/admins.\",\n })\n @ApiQuery({ name: 'page', required: false, type: Number, example: 1 })",
"endLine": 321,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 313
},
{
"code": " @Get('organization/all')\n @UseGuards(JwtAuthGuard)\n @ApiBearerAuth()\n @ApiOperation({\n summary: 'Get organization bookings',\n description:\n \"Retrieve all bookings for the user's organization with pagination. For managers/admins.\",\n })\n @ApiQuery({ name: 'page', required: false, type: Number, example: 1 })\n @ApiQuery({ name: 'limit', required: false, type: Number, example: 10 })\n @ApiResponse({\n status: 200,\n description: 'Organization bookings retrieved successfully',\n type: CsvBookingListResponseDto,\n })\n @ApiResponse({ status: 401, description: 'Unauthorized' })\n async getOrganizationBookings(\n @Request() req: any,\n @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,\n @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number\n ): Promise<CsvBookingListResponseDto> {\n const organizationId = req.user.organizationId;\n return await this.csvBookingService.getOrganizationBookings(organizationId, page, limit);",
"endLine": 335,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 313
},
{
"code": " page,\n limit,\n totalPages: Math.ceil(bookings.length / limit),\n };\n }\n\n /**\n * Get bookings for an organization (paginated)\n */\n async getOrganizationBookings(\n organizationId: string,\n page: number = 1,\n limit: number = 10\n ): Promise<CsvBookingListResponseDto> {\n const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);\n\n // Simple pagination (in-memory)\n const start = (page - 1) * limit;\n const end = start + limit;\n const paginatedBookings = bookings.slice(start, end);\n\n return {",
"endLine": 1221,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 1200
},
{
"code": " // Verify user owns this booking OR is the assigned carrier\n const isOwner = booking.userId === userId;\n const isAssignedCarrier = carrierId && ormBooking?.carrierId === carrierId;\n\n if (!isOwner && !isAssignedCarrier) {\n throw new NotFoundException(`Booking with ID ${id} not found`);\n }\n\n return this.toResponseDto(booking);\n }\n\n /**\n * Get booking by confirmation token (public endpoint)",
"endLine": 697,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e3",
"label": "Source 4",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 685
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_50c8900726aa1ddf77230d4f",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:3f0bb62a45cf42e61ae285fdbde413347a2f73bd14c8dd25600f6d05048cdef6"
},
"identity": {
"anchor": "les-dossiers-des-collegues-sont-accessibles-sans-role-de-gestion"
},
"locations": [
{
"endLine": 321,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "root_control",
"startLine": 313
},
{
"endLine": 335,
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"role": "evidence",
"startLine": 313
},
{
"endLine": 1221,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 1200
},
{
"endLine": 697,
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"role": "evidence",
"startLine": 685
}
],
"occurrenceId": "occ_4868877e84480a9f3396770f",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-3",
"originalCandidates": [
{
"attacker": "Authenticated USER or VIEWER in organization with other users bookings",
"confidence": "high",
"counterevidence": "Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads.",
"cwe": "CWE-862",
"evidence": [
{
"lines": "313-338",
"path": "apps/backend/src/application/controllers/csv-bookings.controller.ts",
"source": "organization/all only @UseGuards(JwtAuthGuard), passes req.user.organizationId"
},
{
"lines": "1200-1221",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "getOrganizationBookings returns all organization records through toResponseDto"
},
{
"lines": "685-697",
"path": "apps/backend/src/application/services/csv-booking.service.ts",
"source": "Individual booking read rejects non-owner/non-carrier"
}
],
"flow": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.",
"invariant": "Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins",
"remediation": "Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility.",
"severity": "medium",
"title": "Organization CSV booking listing lacks manager/admin authorization"
}
],
"source": "local_plugin"
},
"remediation": "Apply RolesGuard and manager/admin roles to organization listing/statistics or explicitly redesign and document CSV visibility.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"summary": "Owner-only CSV booking visibility can be expanded to organization scope only for managers/admins A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately."
},
"ruleId": "authorization.organization-booking-list",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "low",
"rationale": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads."
},
"summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-862"
]
},
"title": "Les dossiers des coll\u00e8gues sont accessibles sans r\u00f4le de gestion",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2",
"e3"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "A normal member calls GET /csv-bookings/organization/all and obtains other users prices, notes, carrier data and document metadata that individual GET denies. Exposed confirmation tokens additionally enable carrier decision spoofing covered separately. Contre-preuves : Organization ID comes from trusted authentication, so no cross-organization listing; ordinary non-CSV bookings intentionally have wider same-organization reads."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"sink": "apps/backend/src/application/controllers/users.controller.ts",
"source": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID",
"summary": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration."
},
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"reachability": {
"attacker": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID",
"entrypoint": "apps/backend/src/application/controllers/users.controller.ts",
"summary": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation."
},
"summary": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration."
},
"codeEvidence": [
{
"code": " // Authorization: Only ADMIN can assign ADMIN role\n if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {\n throw new ForbiddenException('Only platform administrators can assign ADMIN role');\n }\n\n // Authorization: Managers can only update users in their own organization\n if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {\n throw new ForbiddenException('You can only update users in your own organization');\n }",
"endLine": 264,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "root_control",
"startLine": 256
},
{
"code": " if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {\n throw new ForbiddenException('Only platform administrators can assign ADMIN role');\n }\n\n // Authorization: Managers can only update users in their own organization\n if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {\n throw new ForbiddenException('You can only update users in your own organization');\n }\n\n // Update fields\n if (dto.firstName) {\n user.updateFirstName(dto.firstName);\n }\n\n if (dto.lastName) {\n user.updateLastName(dto.lastName);\n }\n\n if (dto.role) {\n const domainRole = dto.role as unknown as DomainUserRole;\n user.updateRole(domainRole);\n }\n",
"endLine": 279,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 257
},
{
"code": "\n // Fetch users from current user's organization\n this.logger.log(\n `[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`\n );",
"endLine": 400,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 396
}
],
"confidence": {
"level": "high",
"rationale": "Tra\u00e7age statique du code courant. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_044db1631e9f89d1b75d672c",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:07810ecb4fc6a43ce1fbde341c16228d5c1744c8781ac75d5c76f0a63946c3de"
},
"identity": {
"anchor": "un-manager-peut-retrograder-un-administrateur-de-son-organisation"
},
"locations": [
{
"endLine": 264,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "root_control",
"startLine": 256
},
{
"endLine": 279,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 257
},
{
"endLine": 400,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 396
}
],
"occurrenceId": "occ_b71f7a24ae63cf0ccd54604c",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "back-8",
"originalCandidates": [
{
"attacker": "MANAGER sharing an organization with an ADMIN and knowing that ADMIN UUID",
"confidence": "high",
"counterevidence": "Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation.",
"cwe": "CWE-863",
"evidence": [
{
"lines": "257-282",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"source": "Only blocks dto.role===ADMIN; same-organization manager otherwise allowed to update role and active status"
},
{
"lines": "396-400",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"source": "List explicitly hides ADMIN users from non-admins"
}
],
"flow": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.",
"invariant": "Managers must not alter platform administrator privileges/status",
"remediation": "Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes.",
"severity": "medium",
"title": "Organization managers can demote or deactivate platform administrators"
}
],
"source": "local_plugin"
},
"remediation": "Reject any non-admin update whose target currently has ADMIN role; enforce explicit actor/target role hierarchy before field changes.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"summary": "Managers must not alter platform administrator privileges/status Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration."
},
"ruleId": "authorization.admin-target-hierarchy",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "low",
"rationale": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation."
},
"summary": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-863"
]
},
"title": "Un manager peut r\u00e9trograder un administrateur de son organisation",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "Manager invokes PATCH /users/<admin-uuid> with role USER or isActive false. Role assignment check only forbids granting ADMIN, not targeting an existing ADMIN; same-organization check passes and update is persisted. Admin loses access to platform administration. Contre-preuves : Requires same organization and UUID knowledge; no ability to promote oneself to ADMIN, and premium user_management gate applies. Hiding admins in list reduces discoverability but does not authorize direct mutation."
}
},
{
"attackPath": {
"dataflow": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"sink": "apps/frontend/src/components/ExportButton.tsx",
"source": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software",
"summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active."
},
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"reachability": {
"attacker": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software",
"entrypoint": "apps/backend/src/application/controllers/users.controller.ts",
"summary": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced."
},
"summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active."
},
"codeEvidence": [
{
"code": " // Update fields\n if (dto.firstName) {\n user.updateFirstName(dto.firstName);\n }\n\n if (dto.lastName) {\n user.updateLastName(dto.lastName);\n }",
"endLine": 273,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e0",
"label": "Source 1",
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 266
},
{
"code": " <ExportButton\n data={allUsers}\n filename={t('exportFilename')}\n columns={[\n { key: 'firstName', label: t('export.firstName') },\n { key: 'lastName', label: t('export.lastName') },\n { key: 'email', label: t('export.email') },",
"endLine": 347,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e1",
"label": "Source 2",
"path": "apps/frontend/app/[locale]/dashboard/settings/users/page.tsx",
"role": "evidence",
"startLine": 341
},
{
"code": " const generateCSV = (): string => {\n const headers = columns.map(col => `\"${col.label.replace(/\"/g, '\"\"')}\"`).join(';');\n\n const rows = data.map(row => {\n return columns\n .map(col => {\n const value = getNestedValue(row, col.key as string);\n const formattedValue = col.format ? col.format(value, row) : formatValue(value);\n return `\"${formattedValue.replace(/\"/g, '\"\"')}\"`;\n })\n .join(';');\n });\n\n return [headers, ...rows].join('\\n');\n };\n",
"endLine": 80,
"explanation": "\u00c9tape du parcours source d\u00e9crit dans la cause et la validation.",
"id": "e2",
"label": "Source 3",
"path": "apps/frontend/src/components/ExportButton.tsx",
"role": "root_control",
"startLine": 65
}
],
"confidence": {
"level": "medium",
"rationale": "Tra\u00e7age statique du code courant. Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced."
},
"extensions": {
"investigator": "Source audit and independent parent validation"
},
"findingId": "csf_0bb11fd71e25c3769cbe03e8",
"fingerprints": {
"algorithm": "codex-security/v1",
"primary": "codex-security/v1:sha256:0de3829c9e9af3e471a70b622b2eae3861b2a0de91d3e83514d30b3bd1d83b85"
},
"identity": {
"anchor": "les-exports-csv-conservent-les-formules-injectees"
},
"locations": [
{
"endLine": 273,
"path": "apps/backend/src/application/controllers/users.controller.ts",
"role": "evidence",
"startLine": 266
},
{
"endLine": 347,
"path": "apps/frontend/app/[locale]/dashboard/settings/users/page.tsx",
"role": "evidence",
"startLine": 341
},
{
"endLine": 80,
"path": "apps/frontend/src/components/ExportButton.tsx",
"role": "root_control",
"startLine": 65
}
],
"occurrenceId": "occ_1a8dda002db49396d99ce0b5",
"preventiveControls": [
"Centraliser le contr\u00f4le et v\u00e9rifier toutes les interfaces vers la m\u00eame ressource."
],
"provenance": {
"candidateId": "front-2",
"originalCandidates": [
{
"attacker": "Authenticated organization manager can modify name of a user in own organization and induce another manager/admin to export CSV and open it in spreadsheet software",
"confidence": "medium",
"control": "CSV quote escaping is not formula neutralization.",
"counterevidence": "Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced.",
"cwe": "CWE-1236",
"evidence": "An accepted firstName =1+1 becomes CSV cell \"=1+1\"; strings containing formula expressions are retained.",
"flow": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.",
"line": 75,
"path": "apps/frontend/src/components/ExportButton.tsx",
"remediation": "Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests.",
"severity": "medium",
"title": "CSV export interprets user-controlled names as spreadsheet formulas"
}
],
"source": "local_plugin"
},
"remediation": "Neutralize formula-leading strings in centralized CSV serializer; preserve typed-string behavior for XLSX/XML and add export-focused regression tests.",
"remediationTests": [
"Reproduire le parcours d\u00e9crit avec des donn\u00e9es de test et v\u00e9rifier le rejet sans effet sur les ressources prot\u00e9g\u00e9es."
],
"rootCause": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"summary": "CSV quote escaping is not formula neutralization. UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active."
},
"ruleId": "formula-injection.csv-export",
"severity": {
"changeConditions": "Les pr\u00e9requis et contr\u00f4les externes d\u00e9crits peuvent r\u00e9duire la s\u00e9v\u00e9rit\u00e9 ; aucun d\u00e9ploiement r\u00e9el n\u2019a \u00e9t\u00e9 test\u00e9.",
"level": "low",
"rationale": "Attaque limit\u00e9e \u00e0 des coll\u00e8gues et n\u00e9cessitant une ouverture dans un tableur qui interpr\u00e8te les formules. Aucune ex\u00e9cution syst\u00e8me ni exfiltration automatique d\u00e9montr\u00e9e."
},
"summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active.",
"taxonomy": {
"category": "Authorization / security control",
"cwe": [
"CWE-1236"
]
},
"title": "Les exports CSV conservent les formules inject\u00e9es",
"validation": {
"evidenceRefs": [
"e0",
"e1",
"e2"
],
"limitations": [
"Pas d\u2019ex\u00e9cution du produit, de test de charge ni d\u2019exploitation r\u00e9seau."
],
"method": "static source trace",
"summary": "UsersController.updateUser persists dto.firstName; DTO only IsString/MinLength, domain trims string. settings/users/page.tsx:341-346 exports firstName/lastName via ExportButton. generateCSV quote-escapes but leaves leading =,+,-,@ active. Contre-preuves : Requires spreadsheet opening, formula behavior varies by client and protective settings; Excel XML export emits typed strings and is not affected. Ordinary user self-profile source was not fully traced."
}
}
],
"scanId": "4c194468-0b5f-4f24-9005-5be211dc0e47",
"schemaVersion": "1.0"
}