From e0b174e18d4977b34d8f0f38767ea64cfa5de1ce Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:31:10 +0000 Subject: [PATCH 01/22] fix(deps): dedupe ioredis so bullmq shares the root client type (OPS-C1) bullmq pins ioredis 5.10.1 while the root depends on 5.11.1, which made tsc fail on the Queue/Worker connection types and broke build, CI and start. An npm override forces a single ioredis copy. --- package-lock.json | 72 ----------------------------------------------- package.json | 3 +- 2 files changed, 2 insertions(+), 73 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9c292cc..06818f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3233,9 +3233,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3250,9 +3247,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3267,9 +3261,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3284,9 +3275,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3301,9 +3289,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3318,9 +3303,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3335,9 +3317,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3352,9 +3331,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3369,9 +3345,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3386,9 +3359,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4024,36 +3994,6 @@ } } }, - "node_modules/bullmq/node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", - "license": "MIT" - }, - "node_modules/bullmq/node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -7402,24 +7342,12 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", diff --git a/package.json b/package.json index 407ee25..b5d7ac0 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "uuid": "^11.1.1", "lodash": "^4.17.21", "validator": "^13.15.21", - "dottie": "^2.0.4" + "dottie": "^2.0.4", + "ioredis": "$ioredis" } } From f7516a3c0f5f92cb286dc74391a0f1c333cec1c6 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:35:27 +0000 Subject: [PATCH 02/22] fix(auth): close public registration and ignore client-supplied roles (SEC-C1) - ALLOW_REGISTRATION defaults to false. While the Users table is empty the first registration is accepted and becomes Admin; afterwards /register returns 403 unless registration is explicitly enabled. - Self-registered accounts are always Viewer; Role is never read from the request. The count check and insert share a SERIALIZABLE transaction so concurrent first registrations cannot create two admins. - Every /api/auth body is validated with a Joi schema (stripUnknown). - New GET /api/auth/registration-status for the login page. - Boot no longer fails on a fresh install without DEFAULT_ADMIN_*; it logs that the first registration creates the administrator. - Replace the test that asserted self-assigned roles with negative tests. --- .env.example | 9 ++ __tests__/integration/Auth.test.ts | 84 ++++++++++++++++- src/Config/AppConfig.ts | 7 ++ src/Controllers/AuthController.ts | 93 +++++++++++++++---- src/Database/DatabaseInitializer.ts | 14 +++ src/Middleware/ValidationMiddleware.ts | 2 +- src/Routes/AuthRoutes.ts | 24 ++++- src/Services/AuthService.ts | 119 ++++++++++++++++++------- 8 files changed, 294 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index 2e17036..271dd90 100644 --- a/.env.example +++ b/.env.example @@ -88,7 +88,16 @@ BACKUP_DIR=./backups BACKUP_RETENTION_DAYS=30 MAX_CONCURRENT_DEPLOYMENTS=5 +# === Registration (v3.0.1 — SEC-C1) === +# Self-service sign-up is CLOSED by default. While the Users table is empty +# the first account registered through /api/auth/register becomes Admin; +# after that, only admins can create accounts unless this is "true" (new +# self-registered accounts are always Viewer). +ALLOW_REGISTRATION=false + # === Default Admin Bootstrap === +# Optional. When set and no admin exists, an admin is created at boot. When +# unset on a fresh install, use the first-admin registration flow instead. DEFAULT_ADMIN_USERNAME=admin DEFAULT_ADMIN_EMAIL=admin@example.com DEFAULT_ADMIN_PASSWORD=change_me_admin_password diff --git a/__tests__/integration/Auth.test.ts b/__tests__/integration/Auth.test.ts index 4a537bd..8ed82d6 100644 --- a/__tests__/integration/Auth.test.ts +++ b/__tests__/integration/Auth.test.ts @@ -16,6 +16,8 @@ import cookieParser from 'cookie-parser'; import express from 'express'; import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; import AuthRoutes from '@Routes/AuthRoutes'; +import AppConfig from '@Config/AppConfig'; +import { User } from '@Models/index'; import { makeUser } from '../helpers/factories'; async function dbReachable(): Promise { @@ -54,16 +56,17 @@ describe('Auth — F-002 integration', () => { if (dbUp) await truncateAll(); }); - it('register → sets cookies and returns the new user', async () => { + it('register on an empty system → first user becomes Admin and gets cookies', async () => { if (!dbUp) return; const res = await request(app).post('/api/auth/register').send({ Username: 'reg_user', Email: 'reg_user@test.local', Password: 'Sup3rSecret!', - Role: 'Developer', + Role: 'viewer', }); expect(res.status).toBe(201); expect(res.body.Data.User.Username).toBe('reg_user'); + expect(res.body.Data.User.Role).toBe('admin'); const setCookie = res.headers['set-cookie']; expect(setCookie).toBeDefined(); @@ -72,6 +75,83 @@ describe('Auth — F-002 integration', () => { expect(cookieStr).toContain('refresh_token='); }); + it('register is closed by default once any user exists (SEC-C1)', async () => { + if (!dbUp) return; + expect(AppConfig.AllowRegistration).toBe(false); + await makeUser({ Role: 'Admin' }); + + const res = await request(app).post('/api/auth/register').send({ + Username: 'intruder', + Email: 'intruder@test.local', + Password: 'Sup3rSecret!', + Role: 'admin', + }); + expect(res.status).toBe(403); + expect(res.headers['set-cookie']).toBeUndefined(); + expect(await User.count({ where: { Username: 'intruder' } })).toBe(0); + }); + + it('register ignores a client-supplied Role even when registration is open (SEC-C1)', async () => { + if (!dbUp) return; + jest.replaceProperty(AppConfig, 'AllowRegistration', true); + await makeUser({ Role: 'Admin' }); + + const res = await request(app).post('/api/auth/register').send({ + Username: 'selfreg', + Email: 'selfreg@test.local', + Password: 'Sup3rSecret!', + Role: 'admin', + IsActive: true, + AccountStatus: 'active', + }); + expect(res.status).toBe(201); + expect(res.body.Data.User.Role).toBe('viewer'); + const stored = await User.findOne({ where: { Username: 'selfreg' } }); + expect(stored?.get('Role')).toBe('viewer'); + }); + + it('register rejects malformed bodies with 400', async () => { + if (!dbUp) return; + const res = await request(app).post('/api/auth/register').send({ + Username: 'bad name; rm -rf /', + Email: 'not-an-email', + Password: 'Sup3rSecret!', + }); + expect(res.status).toBe(400); + expect(await User.count()).toBe(0); + }); + + it('two concurrent first registrations produce exactly one Admin', async () => { + if (!dbUp) return; + const [a, b] = await Promise.all([ + request(app).post('/api/auth/register').send({ + Username: 'race_a', + Email: 'race_a@test.local', + Password: 'Sup3rSecret!', + }), + request(app).post('/api/auth/register').send({ + Username: 'race_b', + Email: 'race_b@test.local', + Password: 'Sup3rSecret!', + }), + ]); + const created = [a, b].filter((r) => r.status === 201); + expect(created).toHaveLength(1); + expect(await User.count()).toBe(1); + expect(await User.count({ where: { Role: 'admin' } })).toBe(1); + }); + + it('registration-status reports first-admin setup, then closed', async () => { + if (!dbUp) return; + const before = await request(app).get('/api/auth/registration-status'); + expect(before.status).toBe(200); + expect(before.body.Data).toEqual({ AllowRegistration: true, SetupRequired: true }); + + await makeUser({ Role: 'Admin' }); + const after = await request(app).get('/api/auth/registration-status'); + expect(after.body.Data).toEqual({ AllowRegistration: false, SetupRequired: false }); + }); + it('login → wrong password → 401', async () => { if (!dbUp) return; await makeUser({ Username: 'login_user', Password: 'CorrectHorse!' }); diff --git a/src/Config/AppConfig.ts b/src/Config/AppConfig.ts index e696b43..c2322d6 100644 --- a/src/Config/AppConfig.ts +++ b/src/Config/AppConfig.ts @@ -122,6 +122,10 @@ export class AppConfig { // Session Configuration public readonly SessionSecret: string; + // Self-service registration (SEC-C1). Off by default: only the very first + // account can be created through /api/auth/register (and becomes Admin). + public readonly AllowRegistration: boolean; + // Default Admin Setup public readonly DefaultAdmin: { Username?: string; @@ -250,6 +254,9 @@ export class AppConfig { // Session Configuration this.SessionSecret = getEnv('SESSION_SECRET', 'change_this_session_secret'); + // Registration policy (SEC-C1) + this.AllowRegistration = getEnv('ALLOW_REGISTRATION', 'false').toLowerCase() === 'true'; + // Deployment Configuration this.Deployment = { Path: getEnv('DEPLOYMENTS_PATH', path.join(process.cwd(), 'deployments')), diff --git a/src/Controllers/AuthController.ts b/src/Controllers/AuthController.ts index cf82c02..aec7ea0 100644 --- a/src/Controllers/AuthController.ts +++ b/src/Controllers/AuthController.ts @@ -5,13 +5,53 @@ */ import { Request, Response } from 'express'; -import AuthService from '@Services/AuthService'; +import Joi from 'joi'; +import AuthService, { RegistrationClosedError } from '@Services/AuthService'; import SessionService from '@Services/SessionService'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import AppConfig from '@Config/AppConfig'; import type { IDeviceInfo } from '@Services/SessionService'; +/** + * Request body schemas for every /api/auth endpoint (SEC-C1). + * Applied by AuthRoutes through ValidationMiddleware.ValidateBody, which runs + * with `stripUnknown: true`, so unexpected fields such as `Role` never reach + * a controller or service. + */ +export const AuthSchemas = { + Register: Joi.object({ + Username: Joi.string() + .trim() + .min(3) + .max(50) + .pattern(/^[A-Za-z0-9_.-]+$/) + .required() + .messages({ + 'string.pattern.base': + 'Username may only contain letters, digits, dot, underscore and hyphen', + }), + Email: Joi.string().trim().email({ tlds: { allow: false } }).max(100).required(), + Password: Joi.string().min(1).max(128).required(), + }), + Login: Joi.object({ + Username: Joi.string().trim().min(1).max(255).required(), + Password: Joi.string().min(1).max(1024).required(), + }), + VerifyTwoFactor: Joi.object({ + UserId: Joi.number().integer().positive().required(), + Code: Joi.string().trim().min(1).max(64).required(), + }), + Refresh: Joi.object({ + RefreshToken: Joi.string().max(4096).optional(), + }), + Logout: Joi.object({}), + ChangePassword: Joi.object({ + OldPassword: Joi.string().min(1).max(1024).required(), + NewPassword: Joi.string().min(1).max(128).required(), + }), +}; + export class AuthController { private readonly AuthService: AuthService; private readonly SessionService: SessionService; @@ -68,31 +108,42 @@ export class AuthController { }; } + /** + * Public registration policy probe (SEC-C1). + * GET /api/auth/registration-status + * + * Lets the login page decide whether to offer "Create account" and lets a + * fresh install show the first-admin setup screen. + */ + public GetRegistrationStatus = async (_req: Request, res: Response): Promise => { + try { + const setupRequired = await this.AuthService.IsSetupRequired(); + ResponseHelper.Success(res, 'Registration status retrieved', { + AllowRegistration: this.Config.AllowRegistration || setupRequired, + SetupRequired: setupRequired, + }); + } catch (error) { + Logger.Error('Failed to get registration status', error as Error); + ResponseHelper.Error(res, 'Failed to retrieve registration status'); + } + }; + /** * Register a new user * POST /api/auth/register + * + * The body has already been validated and stripped by AuthSchemas.Register, + * so a client-supplied `Role` is gone by the time we get here (SEC-C1). */ public Register = async (req: Request, res: Response): Promise => { try { - const { Username, Email, Password, Role } = req.body; + const { Username, Email, Password } = req.body as { + Username: string; + Email: string; + Password: string; + }; - // Validate required fields - if (!Username || !Email || !Password) { - ResponseHelper.ValidationError(res, 'Missing required fields', { - Username: !Username ? 'Username is required' : '', - Email: !Email ? 'Email is required' : '', - Password: !Password ? 'Password is required' : '', - }); - return; - } - - // Register user - const user = await this.AuthService.Register({ - Username, - Email, - Password, - Role, - }); + const user = await this.AuthService.Register({ Username, Email, Password }); const userData = user.toJSON(); @@ -111,6 +162,10 @@ export class AuthController { }, }); } catch (error) { + if (error instanceof RegistrationClosedError) { + ResponseHelper.Forbidden(res, error.message); + return; + } Logger.Error('Registration failed', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); } diff --git a/src/Database/DatabaseInitializer.ts b/src/Database/DatabaseInitializer.ts index 3f0a791..cf1abda 100644 --- a/src/Database/DatabaseInitializer.ts +++ b/src/Database/DatabaseInitializer.ts @@ -215,6 +215,20 @@ export class DatabaseInitializer { const adminConfig = AppConfig.DefaultAdmin; if (!adminConfig.Username || !adminConfig.Email || !adminConfig.Password) { + // Fresh install without DEFAULT_ADMIN_*: allow the first-admin setup + // flow instead of refusing to boot. POST /api/auth/register accepts + // exactly one registration while the Users table is empty and that + // account becomes Admin (SEC-C1). + const userCount = await User.count(); + if (userCount === 0) { + Logger.Warn( + 'No user accounts exist and DEFAULT_ADMIN_* is not set. The first account ' + + 'registered through /api/auth/register will become the administrator. ' + + 'Complete the setup immediately after installation.' + ); + return; + } + const message = 'No administrator accounts exist and DEFAULT_ADMIN_* environment variables are not set.'; Logger.Error(message, undefined, { diff --git a/src/Middleware/ValidationMiddleware.ts b/src/Middleware/ValidationMiddleware.ts index 70a33b2..d8884d5 100644 --- a/src/Middleware/ValidationMiddleware.ts +++ b/src/Middleware/ValidationMiddleware.ts @@ -15,7 +15,7 @@ export class ValidationMiddleware { */ public ValidateBody = (schema: Joi.ObjectSchema) => { return (req: Request, res: Response, next: NextFunction): void => { - const { error, value } = schema.validate(req.body, { + const { error, value } = schema.validate(req.body ?? {}, { abortEarly: false, stripUnknown: true, }); diff --git a/src/Routes/AuthRoutes.ts b/src/Routes/AuthRoutes.ts index 5371da2..9732041 100644 --- a/src/Routes/AuthRoutes.ts +++ b/src/Routes/AuthRoutes.ts @@ -5,32 +5,47 @@ */ import { Router } from 'express'; -import AuthController from '@Controllers/AuthController'; +import AuthController, { AuthSchemas } from '@Controllers/AuthController'; import AuthMiddleware from '@Middleware/AuthMiddleware'; import RateLimiterMiddleware from '@Middleware/RateLimiterMiddleware'; +import ValidationMiddleware from '@Middleware/ValidationMiddleware'; export class AuthRoutes { public Router: Router; private readonly AuthController: AuthController; private readonly AuthMiddleware: AuthMiddleware; private readonly RateLimiter: RateLimiterMiddleware; + private readonly Validation: ValidationMiddleware; constructor() { this.Router = Router(); this.AuthController = new AuthController(); this.AuthMiddleware = new AuthMiddleware(); this.RateLimiter = new RateLimiterMiddleware(); + this.Validation = new ValidationMiddleware(); this.InitializeRoutes(); } private InitializeRoutes(): void { + /** + * GET /api/auth/registration-status + * Whether self-registration is open / first-admin setup is pending + */ + this.Router.get( + '/registration-status', + this.RateLimiter.ApiLimiter, + this.AuthController.GetRegistrationStatus + ); + /** * POST /api/auth/register - * Register a new user + * Register a new user. Closed by default (ALLOW_REGISTRATION=false): + * only the first account on an empty system can be created, as Admin. */ this.Router.post( '/register', this.RateLimiter.AuthLimiter, + this.Validation.ValidateBody(AuthSchemas.Register), this.AuthController.Register ); @@ -41,6 +56,7 @@ export class AuthRoutes { this.Router.post( '/login', this.RateLimiter.AuthLimiter, + this.Validation.ValidateBody(AuthSchemas.Login), this.AuthController.Login ); @@ -51,6 +67,7 @@ export class AuthRoutes { this.Router.post( '/verify-2fa', this.RateLimiter.AuthLimiter, + this.Validation.ValidateBody(AuthSchemas.VerifyTwoFactor), this.AuthController.VerifyTwoFactor ); @@ -61,6 +78,7 @@ export class AuthRoutes { this.Router.post( '/logout', this.RateLimiter.ApiLimiter, + this.Validation.ValidateBody(AuthSchemas.Logout), this.AuthController.Logout ); @@ -71,6 +89,7 @@ export class AuthRoutes { this.Router.post( '/refresh', this.RateLimiter.ApiLimiter, + this.Validation.ValidateBody(AuthSchemas.Refresh), this.AuthController.RefreshToken ); @@ -92,6 +111,7 @@ export class AuthRoutes { '/change-password', this.AuthMiddleware.Authenticate, this.RateLimiter.AuthLimiter, + this.Validation.ValidateBody(AuthSchemas.ChangePassword), this.AuthController.ChangePassword ); } diff --git a/src/Services/AuthService.ts b/src/Services/AuthService.ts index 929ed97..3f9528a 100644 --- a/src/Services/AuthService.ts +++ b/src/Services/AuthService.ts @@ -5,6 +5,7 @@ */ import jwt from 'jsonwebtoken'; +import { Transaction } from 'sequelize'; import { User } from '@Models/index'; import PasswordHelper from '@Utils/PasswordHelper'; import AppConfig from '@Config/AppConfig'; @@ -38,11 +39,22 @@ export interface ILoginResult { TwoFactorRequired: boolean; } +/** + * Self-registration input. `Role` is intentionally absent: the server decides + * the role (SEC-C1). Callers must never forward a client-supplied role here. + */ export interface IRegisterData { Username: string; Email: string; Password: string; - Role?: EUserRole; +} + +/** Thrown when public registration is closed and the system already has users. */ +export class RegistrationClosedError extends Error { + constructor() { + super('Registration is disabled. Ask an administrator to create your account.'); + this.name = 'RegistrationClosedError'; + } } export class AuthService { @@ -51,57 +63,96 @@ export class AuthService { private readonly SessionService = new SessionService(); /** - * Register a new user + * Register a new user (SEC-C1). + * + * Policy: + * - The very first account on an empty system is always allowed and + * becomes Admin (bootstrap path for fresh installs). + * - Every later self-registration requires ALLOW_REGISTRATION=true and + * always creates a Viewer. A client-supplied role is never honoured. + * + * The user-count check and the insert run in one SERIALIZABLE transaction so + * two concurrent "first user" requests cannot both become Admin. */ public async Register(data: IRegisterData): Promise { try { - // Validate password strength const passwordValidation = PasswordHelper.ValidateStrength(data.Password); if (!passwordValidation.IsValid) { throw new Error(`Password validation failed: ${passwordValidation.Errors.join(', ')}`); } - // Check if user already exists - const existingUser = await User.findOne({ - where: { Username: data.Username }, - }); - - if (existingUser) { - throw new Error('Username already exists'); - } - - // Check if email already exists - const existingEmail = await User.findOne({ - where: { Email: data.Email }, - }); - - if (existingEmail) { - throw new Error('Email already exists'); - } - - // Hash password const passwordHash = await PasswordHelper.Hash(data.Password); + const sequelize = User.sequelize; + if (!sequelize) { + throw new Error('Database is not initialized'); + } - // Create user - const user = await User.create({ - Username: data.Username, - Email: data.Email, - PasswordHash: passwordHash, - Role: data.Role || EUserRole.Viewer, - IsActive: true, - TwoFactorEnabled: false, - AccountStatus: EAccountStatus.Active, - } as any); + const user = await sequelize.transaction( + { isolationLevel: Transaction.ISOLATION_LEVELS.SERIALIZABLE }, + async (transaction) => { + const userCount = await User.count({ transaction }); + const isFirstUser = userCount === 0; + + if (!isFirstUser && !this.Config.AllowRegistration) { + throw new RegistrationClosedError(); + } + + const existingUser = await User.findOne({ + where: { Username: data.Username }, + transaction, + }); + if (existingUser) { + throw new Error('Username already exists'); + } + + const existingEmail = await User.findOne({ + where: { Email: data.Email }, + transaction, + }); + if (existingEmail) { + throw new Error('Email already exists'); + } + + return User.create( + { + Username: data.Username, + Email: data.Email, + PasswordHash: passwordHash, + Role: isFirstUser ? EUserRole.Admin : EUserRole.Viewer, + IsActive: true, + TwoFactorEnabled: false, + AccountStatus: EAccountStatus.Active, + } as never, + { transaction } + ); + } + ); const userData = user.toJSON(); - Logger.Info(`User registered successfully: ${userData.Username}`, { userId: userData.Id }); + Logger.Info(`User registered successfully: ${userData.Username}`, { + userId: userData.Id, + role: userData.Role, + }); return user; } catch (error) { - Logger.Error('Failed to register user', error as Error, { username: data.Username }); + if (error instanceof RegistrationClosedError) { + Logger.Warn('Registration attempt rejected: registration is closed'); + } else { + Logger.Error('Failed to register user', error as Error, { username: data.Username }); + } throw error; } } + /** + * True when the system has no user accounts yet (fresh install). The first + * registration on such a system creates the initial Admin. + */ + public async IsSetupRequired(): Promise { + const userCount = await User.count(); + return userCount === 0; + } + /** * Login user with credentials */ From 37d82e97c59a9a8658cf1369c7b7b5baf6e18c31 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:46:10 +0000 Subject: [PATCH 03/22] fix(socket): authenticate connections and authorize room joins (SEC-C2) - io.use() verifies the access_token cookie (or auth.token) and reloads the user, rejecting anonymous, forged, disabled and suspended sessions. - join:project / join:deployment are authorized through the new AccessControlService, the same rules the HTTP layer uses; join:user only accepts the caller's own id. Rooms are decided by the server: the user room, a staff room for Admin/Manager and the accessible project rooms for Developer/Viewer. - Remove global IO.emit broadcasts. Deployment events go to the staff, project and deployment rooms only (once per socket) and carry an allow-listed DTO, never a Sequelize instance or project secrets. - Queue health failure reasons are sent to staff only. - Integration tests cover anonymous/forged/disabled rejection, cross-project joins, foreign user rooms and DTO contents. --- __tests__/integration/SocketAuth.test.ts | 228 ++++++++++++++++++++ src/Services/AccessControlService.ts | 116 +++++++++++ src/Services/SocketService.ts | 251 +++++++++++++++++++---- src/Utils/DtoMapper.ts | 103 ++++++++++ 4 files changed, 662 insertions(+), 36 deletions(-) create mode 100644 __tests__/integration/SocketAuth.test.ts create mode 100644 src/Services/AccessControlService.ts create mode 100644 src/Utils/DtoMapper.ts diff --git a/__tests__/integration/SocketAuth.test.ts b/__tests__/integration/SocketAuth.test.ts new file mode 100644 index 0000000..153f8f8 --- /dev/null +++ b/__tests__/integration/SocketAuth.test.ts @@ -0,0 +1,228 @@ +/** + * Socket.IO authentication + room authorization — v3.0.1 / SEC-C2. + * + * Boots the real SocketService on an ephemeral port and drives it with + * socket.io-client, asserting that anonymous sockets are rejected, joins are + * authorized like the HTTP layer, and deployment events carry DTOs only. + */ + +import http from 'http'; +import { AddressInfo } from 'net'; +import jwt from 'jsonwebtoken'; +import { io as ioClient, Socket as ClientSocket } from 'socket.io-client'; +import AppConfig from '@Config/AppConfig'; +import SocketService from '@Services/SocketService'; +import { Deployment, Project, ProjectMember } from '@Models/index'; +import type { User } from '@Models/User'; +import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; +import { makeDeployment, makeProject, makeUser } from '../helpers/factories'; +import { signAccessToken } from '../helpers/token'; + +async function dbReachable(): Promise { + try { + await setupTestDb(); + return true; + } catch { + return false; + } +} + +describe('Socket.IO auth — SEC-C2', () => { + let dbUp = false; + let httpServer: http.Server; + let url = ''; + const open: ClientSocket[] = []; + + function connect(options: { cookie?: string; token?: string } = {}): ClientSocket { + const socket = ioClient(url, { + path: '/v1/ws', + transports: ['websocket'], + reconnection: false, + forceNew: true, + extraHeaders: options.cookie ? { Cookie: options.cookie } : undefined, + auth: options.token ? { token: options.token } : undefined, + }); + open.push(socket); + return socket; + } + + function connected(socket: ClientSocket): Promise { + return new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('connect_error', (err) => reject(err)); + }); + } + + function rejected(socket: ClientSocket): Promise { + return new Promise((resolve, reject) => { + socket.once('connect', () => reject(new Error('unexpectedly connected'))); + socket.once('connect_error', (err) => resolve(err.message)); + }); + } + + function join( + socket: ClientSocket, + event: string, + id: unknown + ): Promise<{ Ok: boolean; Error?: string }> { + return new Promise((resolve) => socket.emit(event, id, resolve)); + } + + function collect(socket: ClientSocket, event: string): unknown[] { + const received: unknown[] = []; + socket.on(event, (payload: unknown) => received.push(payload)); + return received; + } + + const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + const cookieFor = (user: User): string => `access_token=${signAccessToken(user)}`; + + beforeAll(async () => { + dbUp = await dbReachable(); + if (!dbUp) return; + httpServer = http.createServer(); + SocketService.GetInstance().Initialize(httpServer); + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', () => resolve())); + url = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + open.forEach((s) => s.disconnect()); + if (dbUp) { + await SocketService.GetInstance().Shutdown(); + await new Promise((resolve) => httpServer.close(() => resolve())); + await teardownTestDb(); + } + }); + + beforeEach(async () => { + if (dbUp) await truncateAll(); + }); + + afterEach(() => { + open.splice(0).forEach((s) => s.disconnect()); + }); + + it('rejects an anonymous socket', async () => { + if (!dbUp) return; + expect(await rejected(connect())).toBe('Unauthorized'); + }); + + it('rejects a forged token and a token signed with another secret', async () => { + if (!dbUp) return; + const forged = jwt.sign({ UserId: 1, Role: 'admin' }, 'not-the-server-secret'); + expect(await rejected(connect({ cookie: `access_token=${forged}` }))).toBe('Unauthorized'); + expect(await rejected(connect({ token: 'garbage' }))).toBe('Unauthorized'); + }); + + it('rejects a valid token for a disabled user', async () => { + if (!dbUp) return; + const user = await makeUser({ IsActive: false }); + expect(await rejected(connect({ cookie: cookieFor(user) }))).toBe('Unauthorized'); + }); + + it('accepts the access_token cookie and the auth.token fallback', async () => { + if (!dbUp) return; + const user = await makeUser({ Role: 'Viewer' }); + await connected(connect({ cookie: cookieFor(user) })); + await connected(connect({ token: signAccessToken(user) })); + }); + + it('refuses cross-project and cross-deployment joins for a Developer', async () => { + if (!dbUp) return; + const dev = await makeUser({ Role: 'Developer' }); + const own = await makeProject(); + const other = await makeProject(); + await ProjectMember.create({ ProjectId: own.Id, UserId: dev.Id, Role: 'member', AddedBy: dev.Id }); + const otherDeployment = await makeDeployment({ ProjectId: other.Id }); + const ownDeployment = await makeDeployment({ ProjectId: own.Id }); + + const socket = connect({ cookie: cookieFor(dev) }); + await connected(socket); + + expect(await join(socket, 'join:project', other.Id)).toEqual({ Ok: false, Error: 'Forbidden' }); + expect(await join(socket, 'join:deployment', otherDeployment.Id)).toEqual({ + Ok: false, + Error: 'Forbidden', + }); + expect(await join(socket, 'join:project', own.Id)).toEqual({ Ok: true }); + expect(await join(socket, 'join:deployment', ownDeployment.Id)).toEqual({ Ok: true }); + expect((await join(socket, 'join:project', '1 OR 1=1')).Ok).toBe(false); + }); + + it('refuses joining another user room', async () => { + if (!dbUp) return; + const a = await makeUser(); + const b = await makeUser(); + const socket = connect({ cookie: cookieFor(a) }); + await connected(socket); + expect((await join(socket, 'join:user', b.Id)).Ok).toBe(false); + expect((await join(socket, 'join:user', a.Id)).Ok).toBe(true); + + const revoked = collect(socket, 'session:revoked'); + SocketService.GetInstance().EmitSessionRevoked(b.Id, 99); + await wait(150); + expect(revoked).toHaveLength(0); + }); + + it('delivers deployment events only to authorized sockets, once, as a DTO', async () => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const dev = await makeUser({ Role: 'Developer' }); + const outsider = await makeUser({ Role: 'Developer' }); + const project = await makeProject({ CreatedBy: admin.Id }); + await ProjectMember.create({ ProjectId: project.Id, UserId: dev.Id, Role: 'member', AddedBy: admin.Id }); + const created = await makeDeployment({ ProjectId: project.Id }); + const deployment = await Deployment.findByPk(created.Id, { + include: [{ model: Project, as: 'Project' }], + }); + + const adminSocket = connect({ cookie: cookieFor(admin) }); + const devSocket = connect({ cookie: cookieFor(dev) }); + const outsiderSocket = connect({ cookie: cookieFor(outsider) }); + const anonymousRejected = rejected(connect()); + await Promise.all([connected(adminSocket), connected(devSocket), connected(outsiderSocket)]); + expect(await anonymousRejected).toBe('Unauthorized'); + // Admin also sits in the project and deployment rooms: still one delivery. + await join(adminSocket, 'join:project', project.Id); + await join(adminSocket, 'join:deployment', created.Id); + + const adminEvents = collect(adminSocket, 'deployment:updated'); + const devEvents = collect(devSocket, 'deployment:updated'); + const outsiderEvents = collect(outsiderSocket, 'deployment:updated'); + + SocketService.GetInstance().EmitDeploymentUpdate(deployment as Deployment); + await wait(200); + + expect(adminEvents).toHaveLength(1); + expect(devEvents).toHaveLength(1); + expect(outsiderEvents).toHaveLength(0); + + const payload = devEvents[0] as Record; + const serialized = JSON.stringify(payload); + expect(payload.Id).toBe(created.Id); + expect(serialized).not.toContain('WebhookSecret'); + expect(serialized).not.toContain(project.get('WebhookSecret') as string); + expect(serialized).not.toContain('SshKeyEncrypted'); + expect(serialized).not.toContain('"Config"'); + expect((payload.Project as Record).Name).toBe(project.Name); + }); + + it('does not deliver live logs to a socket that was refused the deployment room', async () => { + if (!dbUp) return; + const outsider = await makeUser({ Role: 'Viewer' }); + const deployment = await makeDeployment(); + const socket = connect({ cookie: cookieFor(outsider) }); + await connected(socket); + await join(socket, 'join:deployment', deployment.Id); + + const logs = collect(socket, 'deployment:log'); + SocketService.GetInstance().EmitDeploymentLog(deployment.Id, 'secret build output'); + await wait(150); + expect(logs).toHaveLength(0); + }); + + it('keeps the JWT secret configured for tests', () => { + expect(AppConfig.Jwt.Secret.length).toBeGreaterThan(32); + }); +}); diff --git a/src/Services/AccessControlService.ts b/src/Services/AccessControlService.ts new file mode 100644 index 0000000..437a9a4 --- /dev/null +++ b/src/Services/AccessControlService.ts @@ -0,0 +1,116 @@ +/** + * AccessControlService — single source of truth for project / deployment + * authorization (v3.0.1, SEC-C2 / SEC-H4). + * + * Used by ProjectAccessMiddleware, DeploymentAccessMiddleware and the + * Socket.IO room authorization so HTTP and WebSocket enforce identical rules. + * + * RBAC (see CLAUDE.md): + * - Admin / Manager: every project. + * - Developer: view + modify projects they are a member of (or created). + * - Viewer: view-only, projects they are a member of. + */ + +import { Deployment, Project, ProjectMember } from '@Models/index'; +import { EUserRole } from '@Types/ICommon'; + +export interface IAccessSubject { + UserId: number; + Role: EUserRole; +} + +export type TAccessibleProjects = 'all' | number[]; + +export class AccessControlService { + /** Admin and Manager see and manage every project. */ + public static IsStaff(subject: IAccessSubject): boolean { + return subject.Role === EUserRole.Admin || subject.Role === EUserRole.Manager; + } + + /** Strictly positive integer id, or null. Rejects "1abc", "1.5", "-1". */ + public static ParseId(raw: unknown): number | null { + if (typeof raw === 'number') { + return Number.isInteger(raw) && raw > 0 ? raw : null; + } + if (typeof raw === 'string' && /^[1-9][0-9]{0,9}$/.test(raw)) { + return parseInt(raw, 10); + } + return null; + } + + /** + * Developer / Viewer relationship to a project: a ProjectMember row, or the + * legacy CreatedBy ownership for projects created before memberships existed. + */ + public static async IsMemberOrCreator(userId: number, projectId: number): Promise { + const membership = await ProjectMember.findOne({ + where: { ProjectId: projectId, UserId: userId }, + attributes: ['Id'], + }); + if (membership) return true; + + const project = await Project.findByPk(projectId, { attributes: ['Id', 'CreatedBy'] }); + return Boolean(project && project.CreatedBy === userId); + } + + public static async CanViewProject(subject: IAccessSubject, projectId: number): Promise { + if (AccessControlService.IsStaff(subject)) return true; + if (subject.Role !== EUserRole.Developer && subject.Role !== EUserRole.Viewer) return false; + return AccessControlService.IsMemberOrCreator(subject.UserId, projectId); + } + + public static async CanModifyProject( + subject: IAccessSubject, + projectId: number + ): Promise { + if (AccessControlService.IsStaff(subject)) return true; + if (subject.Role !== EUserRole.Developer) return false; + return AccessControlService.IsMemberOrCreator(subject.UserId, projectId); + } + + /** Resolve the project a deployment belongs to (null when it does not exist). */ + public static async GetDeploymentProjectId(deploymentId: number): Promise { + const deployment = await Deployment.findByPk(deploymentId, { + attributes: ['Id', 'ProjectId'], + }); + return deployment ? deployment.ProjectId : null; + } + + public static async CanViewDeployment( + subject: IAccessSubject, + deploymentId: number + ): Promise { + const projectId = await AccessControlService.GetDeploymentProjectId(deploymentId); + if (projectId === null) return false; + return AccessControlService.CanViewProject(subject, projectId); + } + + public static async CanModifyDeployment( + subject: IAccessSubject, + deploymentId: number + ): Promise { + const projectId = await AccessControlService.GetDeploymentProjectId(deploymentId); + if (projectId === null) return false; + return AccessControlService.CanModifyProject(subject, projectId); + } + + /** 'all' for staff, otherwise the ids of projects the user may view. */ + public static async GetAccessibleProjectIds( + subject: IAccessSubject + ): Promise { + if (AccessControlService.IsStaff(subject)) return 'all'; + if (subject.Role !== EUserRole.Developer && subject.Role !== EUserRole.Viewer) return []; + + const [memberships, created] = await Promise.all([ + ProjectMember.findAll({ where: { UserId: subject.UserId }, attributes: ['ProjectId'] }), + Project.findAll({ where: { CreatedBy: subject.UserId }, attributes: ['Id'] }), + ]); + + const ids = new Set(); + memberships.forEach((m) => ids.add(m.get('ProjectId'))); + created.forEach((p) => ids.add(p.Id)); + return Array.from(ids); + } +} + +export default AccessControlService; diff --git a/src/Services/SocketService.ts b/src/Services/SocketService.ts index 58369d5..0606d98 100644 --- a/src/Services/SocketService.ts +++ b/src/Services/SocketService.ts @@ -1,8 +1,45 @@ import { Server as HttpServer } from 'http'; import { Server as SocketIOServer, Socket } from 'socket.io'; +import jwt from 'jsonwebtoken'; import AppConfig from '@Config/AppConfig'; import Logger from '@Utils/Logger'; -import { Deployment } from '@Models/index'; +import { User } from '@Models/index'; +import { EAccountStatus } from '@Types/ICommon'; +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; +import { ToDeploymentDto } from '@Utils/DtoMapper'; + +/** Room every Admin / Manager socket joins: receives events for all projects. */ +export const STAFF_ROOM = 'scope:all-projects'; +/** Room every authenticated socket joins (non-sensitive system events). */ +export const AUTHENTICATED_ROOM = 'scope:authenticated'; + +export interface ISocketUser extends IAccessSubject { + Username: string; +} + +type TJoinAck = (result: { Ok: boolean; Error?: string }) => void; + +interface IDeploymentLike { + Id: number; + ProjectId: number; +} + +/** Read one cookie value from a raw Cookie header without extra dependencies. */ +export function ReadCookie(header: string | undefined, name: string): string | null { + if (!header) return null; + for (const part of header.split(';')) { + const index = part.indexOf('='); + if (index === -1) continue; + if (part.slice(0, index).trim() !== name) continue; + const raw = part.slice(index + 1).trim(); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + } + return null; +} export class SocketService { private static Instance: SocketService; @@ -46,6 +83,21 @@ export class SocketService { path: '/v1/ws', }); + // SEC-C2: every connection must present a valid access token. Anonymous + // sockets are rejected before any event handler is attached. + this.IO.use((socket, next) => { + this.AuthenticateSocket(socket) + .then(async (user) => { + socket.data.user = user; + socket.data.rooms = await this.ResolveInitialRooms(user); + next(); + }) + .catch((error: Error) => { + Logger.Warn('Socket connection rejected', { reason: error.message, socketId: socket.id }); + next(new Error('Unauthorized')); + }); + }); + this.IO.on('connection', (socket: Socket) => { this.HandleConnection(socket); }); @@ -54,27 +106,98 @@ export class SocketService { } /** - * Handle new socket connection + * Close the Socket.IO server (graceful shutdown and test teardown). + */ + public async Shutdown(): Promise { + if (!this.IO) return; + const io = this.IO; + this.IO = undefined; + await new Promise((resolve) => { + void io.close(() => resolve()); + }); + } + + /** + * Validate the handshake credentials and load the current user from the DB. + * Accepts the httpOnly `access_token` cookie (browsers) or `auth.token` + * (non-browser clients). The DB lookup means disabled / suspended accounts + * are rejected even while their JWT is still unexpired. + */ + private async AuthenticateSocket(socket: Socket): Promise { + const cookieToken = ReadCookie(socket.handshake.headers.cookie, 'access_token'); + const authToken = + typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null; + const token = cookieToken || authToken; + if (!token) { + throw new Error('No token provided'); + } + + const payload = jwt.verify(token, AppConfig.Jwt.Secret, { algorithms: ['HS256'] }) as { + UserId?: unknown; + }; + const userId = AccessControlService.ParseId(payload.UserId); + if (userId === null) { + throw new Error('Malformed token payload'); + } + return this.LoadActiveUser(userId); + } + + private async LoadActiveUser(userId: number): Promise { + const user = await User.findByPk(userId); + if (!user) throw new Error('User not found'); + if (!user.get('IsActive')) throw new Error('User account is disabled'); + if (user.get('AccountStatus') && user.get('AccountStatus') !== EAccountStatus.Active) { + throw new Error('User account is not active'); + } + return { + UserId: user.Id, + Username: user.Username, + Role: user.Role, + }; + } + + /** + * Rooms are decided by the server: the user's own room, the staff room for + * Admin/Manager, and one room per accessible project for Developer/Viewer. + */ + private async ResolveInitialRooms(user: ISocketUser): Promise { + const rooms = [`user:${user.UserId}`, AUTHENTICATED_ROOM]; + const accessible = await AccessControlService.GetAccessibleProjectIds(user); + if (accessible === 'all') { + rooms.push(STAFF_ROOM); + } else { + accessible.forEach((projectId) => rooms.push(`project:${projectId}`)); + } + return rooms; + } + + /** + * Handle a new, already-authenticated socket connection. Handlers are + * attached synchronously so events sent right after `connect` are not lost. */ private HandleConnection(socket: Socket): void { - Logger.Info(`Socket connected: ${socket.id}`); + const user = socket.data.user as ISocketUser; + Logger.Info(`Socket connected: ${socket.id}`, { userId: user.UserId }); + + void socket.join((socket.data.rooms as string[] | undefined) ?? [`user:${user.UserId}`]); - // Join room based on project if needed - socket.on('join:project', async (projectId: number) => { - await socket.join(`project:${projectId}`); - Logger.Info(`Socket ${socket.id} joined project:${projectId}`); + socket.on('join:project', (projectId: unknown, ack?: unknown) => { + void this.HandleJoin(socket, 'project', projectId, ack); }); - // Join room based on deployment if needed - socket.on('join:deployment', async (deploymentId: number) => { - await socket.join(`deployment:${deploymentId}`); - Logger.Info(`Socket ${socket.id} joined deployment:${deploymentId}`); + socket.on('join:deployment', (deploymentId: unknown, ack?: unknown) => { + void this.HandleJoin(socket, 'deployment', deploymentId, ack); }); - // Join room based on userId for session management - socket.on('join:user', async (userId: number) => { - await socket.join(`user:${userId}`); - Logger.Info(`Socket ${socket.id} joined user:${userId}`); + // Backward compatibility: v2.1/v3.0 clients emit join:user with their own + // id. The user room is already joined from the verified token; any other + // id is refused. + socket.on('join:user', (userId: unknown, ack?: unknown) => { + const ok = AccessControlService.ParseId(userId) === user.UserId; + if (!ok) { + Logger.Warn('Socket join:user denied', { socketId: socket.id, userId: user.UserId }); + } + this.Ack(ack, ok ? { Ok: true } : { Ok: false, Error: 'Forbidden' }); }); socket.on('disconnect', () => { @@ -83,29 +206,85 @@ export class SocketService { } /** - * v3.0 F-001 — emit queue health transition to all connected clients. - * Fired by RedisConfig lifecycle listeners on connect / error / end. - * v2.1 clients ignore the unknown event safely. + * Authorize and perform a room join with the same rules as the HTTP layer. + * The user is re-read from the DB so a role change or deactivation applies + * to an already-open socket on its next join. + */ + private async HandleJoin( + socket: Socket, + kind: 'project' | 'deployment', + rawId: unknown, + ack: unknown + ): Promise { + const id = AccessControlService.ParseId(rawId); + if (id === null) { + this.Ack(ack, { Ok: false, Error: 'Invalid id' }); + return; + } + + try { + const current = await this.LoadActiveUser((socket.data.user as ISocketUser).UserId); + socket.data.user = current; + + const allowed = + kind === 'project' + ? await AccessControlService.CanViewProject(current, id) + : await AccessControlService.CanViewDeployment(current, id); + + if (!allowed) { + Logger.Warn(`Socket join:${kind} denied`, { + socketId: socket.id, + userId: current.UserId, + [`${kind}Id`]: id, + }); + this.Ack(ack, { Ok: false, Error: 'Forbidden' }); + return; + } + + await socket.join(`${kind}:${id}`); + Logger.Info(`Socket ${socket.id} joined ${kind}:${id}`); + this.Ack(ack, { Ok: true }); + } catch (error) { + Logger.Warn(`Socket join:${kind} failed`, { socketId: socket.id, reason: (error as Error).message }); + this.Ack(ack, { Ok: false, Error: 'Forbidden' }); + if (/disabled|not active|not found/i.test((error as Error).message)) { + socket.disconnect(true); + } + } + } + + private Ack(ack: unknown, result: { Ok: boolean; Error?: string }): void { + if (typeof ack === 'function') { + (ack as TJoinAck)(result); + } + } + + /** Rooms that should receive an event about a deployment. */ + private DeploymentRooms(deployment: IDeploymentLike): string[] { + return [STAFF_ROOM, `project:${deployment.ProjectId}`, `deployment:${deployment.Id}`]; + } + + /** + * v3.0 F-001 — emit queue health transitions. Only authenticated sockets + * exist; the failure reason (which can contain internal host details) is + * sent to staff only. */ public EmitQueueHealth(ready: boolean, reason?: string): void { if (!this.IO) return; - this.IO.emit('queue:health', { ready, reason }); + this.IO.to(STAFF_ROOM).emit('queue:health', { ready, reason }); + this.IO.to(AUTHENTICATED_ROOM).except(STAFF_ROOM).emit('queue:health', { ready }); } /** - * Emit deployment update event + * Emit deployment update event. Delivered once per socket to staff, the + * project room and the deployment room; never broadcast globally. */ - public EmitDeploymentUpdate(deployment: Deployment): void { + public EmitDeploymentUpdate(deployment: IDeploymentLike): void { if (!this.IO) return; - - // Emit to global listeners (e.g., deployments list, queue) - this.IO.emit('deployment:updated', deployment); - - // Emit to specific project room - this.IO.to(`project:${deployment.ProjectId}`).emit('deployment:updated', deployment); - - // Emit to specific deployment room - this.IO.to(`deployment:${deployment.Id}`).emit('deployment:updated', deployment); + this.IO.to(this.DeploymentRooms(deployment)).emit( + 'deployment:updated', + ToDeploymentDto(deployment) + ); } /** @@ -156,14 +335,14 @@ export class SocketService { } /** - * Emit deployment completed event + * Emit deployment completed event (same rooms and DTO as updates). */ - public EmitDeploymentCompleted(deployment: Deployment): void { + public EmitDeploymentCompleted(deployment: IDeploymentLike): void { if (!this.IO) return; - - this.IO.emit('deployment:completed', deployment); - this.IO.to(`project:${deployment.ProjectId}`).emit('deployment:completed', deployment); - this.IO.to(`deployment:${deployment.Id}`).emit('deployment:completed', deployment); + this.IO.to(this.DeploymentRooms(deployment)).emit( + 'deployment:completed', + ToDeploymentDto(deployment) + ); } /** diff --git a/src/Utils/DtoMapper.ts b/src/Utils/DtoMapper.ts new file mode 100644 index 0000000..00cb6f6 --- /dev/null +++ b/src/Utils/DtoMapper.ts @@ -0,0 +1,103 @@ +/** + * DtoMapper — explicit allow-list projections for data that leaves the + * server (Socket.IO events, REST responses). v3.0.1, SEC-C2 / SEC-H3. + * + * Never send Sequelize instances or raw rows to clients: a model's toJSON() + * includes every column, which is how webhook secrets and encrypted SSH keys + * leaked. Every field that goes out must be named here. + */ + +import type { Model } from 'sequelize'; + +type TRow = Record; + +function ToPlain(value: unknown): TRow | null { + if (!value || typeof value !== 'object') return null; + const maybeModel = value as Partial & { toJSON?: () => unknown }; + if (typeof maybeModel.toJSON === 'function') { + const json = maybeModel.toJSON(); + return json && typeof json === 'object' ? (json as TRow) : null; + } + return value as TRow; +} + +function Pick(row: TRow, keys: readonly string[]): TRow { + const out: TRow = {}; + for (const key of keys) { + if (row[key] !== undefined) out[key] = row[key]; + } + return out; +} + +/** + * Strip `user:password@` credentials from a repository URL so tokens embedded + * in https clone URLs are never echoed back. scp-style `git@host:path` URLs + * have no secret component and pass through unchanged. + */ +export function SanitizeRepoUrl(repoUrl: unknown): unknown { + if (typeof repoUrl !== 'string') return repoUrl; + try { + const parsed = new URL(repoUrl); + if (parsed.password || (parsed.username && parsed.protocol.startsWith('http'))) { + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } + return repoUrl; + } catch { + return repoUrl; + } +} + +export const PROJECT_SUMMARY_FIELDS = [ + 'Id', + 'Name', + 'Description', + 'RepoUrl', + 'Branch', + 'ProjectType', + 'IsActive', + 'WorkspaceId', +] as const; + +/** Minimal project shape embedded in deployment events and lists. */ +export function ToProjectSummaryDto(project: unknown): TRow | null { + const row = ToPlain(project); + if (!row) return null; + const dto = Pick(row, PROJECT_SUMMARY_FIELDS); + if ('RepoUrl' in dto) dto.RepoUrl = SanitizeRepoUrl(dto.RepoUrl); + return dto; +} + +export const DEPLOYMENT_FIELDS = [ + 'Id', + 'ProjectId', + 'CommitHash', + 'Branch', + 'Status', + 'TriggerType', + 'TriggeredBy', + 'StartedAt', + 'CompletedAt', + 'Duration', + 'ErrorMessage', + 'CommitMessage', + 'CommitAuthor', + 'Author', + 'QueueJobId', + 'CreatedAt', + 'UpdatedAt', +] as const; + +/** Deployment as sent over Socket.IO: no log paths, no full project row. */ +export function ToDeploymentDto(deployment: unknown): TRow | null { + const row = ToPlain(deployment); + if (!row) return null; + const dto = Pick(row, DEPLOYMENT_FIELDS); + if (row.Project) { + dto.Project = ToProjectSummaryDto(row.Project); + } + return dto; +} + +export default { ToDeploymentDto, ToProjectSummaryDto, SanitizeRepoUrl }; From e6d0413028acd55f30f1ededef272a313d3c2341 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:53:06 +0000 Subject: [PATCH 04/22] fix(auth): bind the 2FA step to the password step and add lockout (SEC-C3) - A correct password for a 2FA account now issues a random single-use token (5 minutes, stored hashed in MfaChallenges) in an httpOnly mfa_pending cookie scoped to /api/auth. The login response no longer contains the user id. - /verify-2fa derives the user from that challenge only; a body UserId is stripped. The challenge is claimed atomically before the code is checked, so it cannot be replayed or raced, and it is burned after 5 wrong codes. - Per-account lockout: 5 consecutive failures lock the second factor for 15 minutes regardless of IP or challenge. - IsActive, AccountStatus and TwoFactorEnabled are re-checked at the second step. - Backup codes are 80-bit (XXXX-XXXX-XXXX-XXXX), stored as salted SHA-256 hashes. Pre-3.0.1 codes (about 20 bits) are no longer accepted; the 2FA status reports legacyBackupCodesInvalidated so users regenerate. - Migration 022 adds MfaChallenges and TwoFactorAuth.FailedAttempts / LockedUntil (idempotent, with down()). --- __tests__/integration/TwoFactorLogin.test.ts | 245 ++++++++++++++++++ src/Controllers/AuthController.ts | 64 ++++- src/Database/MigrationRunner.ts | 8 + ...2_create_mfa_challenges_and_2fa_lockout.ts | 101 ++++++++ src/Models/MfaChallenge.ts | 107 ++++++++ src/Models/TwoFactorAuth.ts | 14 + src/Models/index.ts | 3 + src/Services/AuthService.ts | 133 ++++++++-- src/Services/TwoFactorAuthService.ts | 158 +++++++++-- src/Types/IDatabase.ts | 2 + 10 files changed, 787 insertions(+), 48 deletions(-) create mode 100644 __tests__/integration/TwoFactorLogin.test.ts create mode 100644 src/Migrations/022_create_mfa_challenges_and_2fa_lockout.ts create mode 100644 src/Models/MfaChallenge.ts diff --git a/__tests__/integration/TwoFactorLogin.test.ts b/__tests__/integration/TwoFactorLogin.test.ts new file mode 100644 index 0000000..1416a35 --- /dev/null +++ b/__tests__/integration/TwoFactorLogin.test.ts @@ -0,0 +1,245 @@ +/** + * Two-factor login binding — v3.0.1 / SEC-C3. + * + * The second step must be bound to the password step through the httpOnly + * mfa_pending cookie; a body-supplied UserId must never be enough. + */ + +import request from 'supertest'; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import crypto from 'crypto'; +import speakeasy from 'speakeasy'; +import AuthRoutes from '@Routes/AuthRoutes'; +import TwoFactorAuthService from '@Services/TwoFactorAuthService'; +import { MfaChallenge, TwoFactorAuth, User } from '@Models/index'; +import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; +import { makeUser } from '../helpers/factories'; + +async function dbReachable(): Promise { + try { + await setupTestDb(); + return true; + } catch { + return false; + } +} + +const PASSWORD = 'Hunter22!Strong'; + +describe('Two-factor login — SEC-C3', () => { + let dbUp = false; + let app: express.Application; + const twoFactor = new TwoFactorAuthService(); + + async function makeTwoFactorUser(): Promise<{ user: User; secret: string; backupCodes: string[] }> { + const user = await makeUser({ Username: `mfa_${crypto.randomBytes(3).toString('hex')}`, Password: PASSWORD }); + const { secret } = await twoFactor.GenerateTOTP(user.Id); + const { backupCodes } = await twoFactor.Enable2FA( + user.Id, + speakeasy.totp({ secret, encoding: 'base32' }) + ); + return { user, secret, backupCodes }; + } + + const totp = (secret: string): string => speakeasy.totp({ secret, encoding: 'base32' }); + + function cookieHeader(res: request.Response): string { + const raw = res.headers['set-cookie'] as unknown; + return Array.isArray(raw) ? raw.join(';') : String(raw ?? ''); + } + + async function passwordStep(agent: ReturnType, username: string): Promise { + return agent.post('/api/auth/login').send({ Username: username, Password: PASSWORD }); + } + + // A fresh router per app means a fresh in-memory per-IP AuthLimiter, so + // one test's failures never surface as 429s in the next. + function buildApp(): express.Application { + const fresh = express(); + fresh.use(express.json()); + fresh.use(cookieParser()); + fresh.use('/api/auth', new AuthRoutes().Router); + return fresh; + } + + beforeAll(async () => { + dbUp = await dbReachable(); + }); + + afterAll(async () => { + if (dbUp) await teardownTestDb(); + }); + + beforeEach(async () => { + if (!dbUp) return; + await truncateAll(); + app = buildApp(); + }); + + it('password step issues an httpOnly mfa_pending cookie and no session', async () => { + if (!dbUp) return; + const { user } = await makeTwoFactorUser(); + const res = await passwordStep(request.agent(app), user.Username); + + expect(res.status).toBe(200); + expect(res.body.Data.TwoFactorRequired).toBe(true); + expect(res.body.Data.UserId).toBeUndefined(); + const cookies = cookieHeader(res); + expect(cookies).toMatch(/mfa_pending=[^;]+/); + expect(cookies).toMatch(/HttpOnly/i); + expect(cookies).not.toContain('access_token='); + }); + + it('rejects the legacy UserId + Code call without the cookie', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const res = await request(app) + .post('/api/auth/verify-2fa') + .send({ UserId: user.Id, Code: totp(secret) }); + expect(res.status).toBe(401); + expect(cookieHeader(res)).not.toContain('access_token='); + }); + + it('ignores a body UserId pointing at another account', async () => { + if (!dbUp) return; + const victim = await makeTwoFactorUser(); + const attacker = await makeTwoFactorUser(); + const agent = request.agent(app); + await passwordStep(agent, attacker.user.Username); + + // Attacker's own challenge + victim's id and code: the server must use + // the challenge's user, so the victim's TOTP is just a wrong code. + const res = await agent + .post('/api/auth/verify-2fa') + .send({ UserId: victim.user.Id, Code: totp(victim.secret) }); + if (res.status === 200) { + expect(res.body.Data.User.Id).toBe(attacker.user.Id); + } else { + expect(res.status).toBe(401); + } + expect(res.body.Data?.User?.Id).not.toBe(victim.user.Id); + }); + + it('accepts the correct code once and the cookie cannot be replayed', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const agent = request.agent(app); + const login = await passwordStep(agent, user.Username); + const pending = cookieHeader(login).match(/mfa_pending=[^;]+/)?.[0] ?? ''; + + const ok = await agent.post('/api/auth/verify-2fa').send({ Code: totp(secret) }); + expect(ok.status).toBe(200); + expect(cookieHeader(ok)).toContain('access_token='); + + const replay = await request(app) + .post('/api/auth/verify-2fa') + .set('Cookie', pending) + .send({ Code: totp(secret) }); + expect(replay.status).toBe(401); + }); + + it('lets only one of two concurrent verifications succeed', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const login = await passwordStep(request.agent(app), user.Username); + const pending = cookieHeader(login).match(/mfa_pending=[^;]+/)?.[0] ?? ''; + const code = totp(secret); + + const results = await Promise.all([ + request(app).post('/api/auth/verify-2fa').set('Cookie', pending).send({ Code: code }), + request(app).post('/api/auth/verify-2fa').set('Cookie', pending).send({ Code: code }), + ]); + expect(results.filter((r) => r.status === 200)).toHaveLength(1); + }); + + it('rejects an expired challenge', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const agent = request.agent(app); + await passwordStep(agent, user.Username); + await MfaChallenge.update( + { ExpiresAt: new Date(Date.now() - 1000) }, + { where: { UserId: user.Id } } + ); + const res = await agent.post('/api/auth/verify-2fa').send({ Code: totp(secret) }); + expect(res.status).toBe(401); + }); + + it('re-checks IsActive between the two steps', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const agent = request.agent(app); + await passwordStep(agent, user.Username); + await User.update({ IsActive: false }, { where: { Id: user.Id } }); + const res = await agent.post('/api/auth/verify-2fa').send({ Code: totp(secret) }); + expect(res.status).toBe(401); + expect(cookieHeader(res)).not.toContain('access_token='); + }); + + it('locks the account after repeated wrong codes, even across new challenges', async () => { + if (!dbUp) return; + const { user, secret } = await makeTwoFactorUser(); + const agent = request.agent(app); + await passwordStep(agent, user.Username); + + const statuses: number[] = []; + for (let i = 0; i < 5; i += 1) { + const res = await agent.post('/api/auth/verify-2fa').send({ Code: '000000' }); + statuses.push(res.status); + } + expect(statuses.slice(0, 4)).toEqual([401, 401, 401, 401]); + expect(statuses[4]).toBe(429); + const lockRow = await TwoFactorAuth.findOne({ where: { UserId: user.Id } }); + expect(lockRow?.get('LockedUntil')).toBeTruthy(); + + // New app (clean per-IP limiter), fresh password step, correct code: + // the account lockout alone must still refuse it. + const again = request.agent(buildApp()); + const relogin = await passwordStep(again, user.Username); + expect(relogin.status).toBe(200); + const locked = await again.post('/api/auth/verify-2fa').send({ Code: totp(secret) }); + expect(locked.status).toBe(429); + expect(locked.body.Message).toMatch(/two-factor/i); + }); + + it('issues 10 strong backup codes, each usable exactly once', async () => { + if (!dbUp) return; + const { user, backupCodes } = await makeTwoFactorUser(); + expect(backupCodes).toHaveLength(10); + backupCodes.forEach((code) => expect(code).toMatch(/^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/)); + expect(new Set(backupCodes).size).toBe(10); + + const stored = JSON.stringify((await TwoFactorAuth.findOne({ where: { UserId: user.Id } }))?.get('BackupCodes')); + backupCodes.forEach((code) => expect(stored).not.toContain(code.replace(/-/g, ''))); + + const first = request.agent(app); + await passwordStep(first, user.Username); + const used = await first.post('/api/auth/verify-2fa').send({ Code: backupCodes[0]!.toLowerCase() }); + expect(used.status).toBe(200); + + const second = request.agent(app); + await passwordStep(second, user.Username); + const reused = await second.post('/api/auth/verify-2fa').send({ Code: backupCodes[0] }); + expect(reused.status).toBe(401); + }); + + it('no longer accepts legacy weak backup codes', async () => { + if (!dbUp) return; + const { user } = await makeTwoFactorUser(); + const legacy = crypto.createHash('sha256').update('ABCDE').digest('hex'); + await TwoFactorAuth.update( + { BackupCodes: JSON.stringify([legacy]) }, + { where: { UserId: user.Id } } + ); + + const agent = request.agent(app); + await passwordStep(agent, user.Username); + const res = await agent.post('/api/auth/verify-2fa').send({ Code: 'ABCDE' }); + expect(res.status).toBe(401); + + const status = await twoFactor.GetStatus(user.Id); + expect(status.legacyBackupCodesInvalidated).toBe(true); + expect(status.backupCodesRemaining).toBe(0); + }); +}); diff --git a/src/Controllers/AuthController.ts b/src/Controllers/AuthController.ts index aec7ea0..9d2fdf0 100644 --- a/src/Controllers/AuthController.ts +++ b/src/Controllers/AuthController.ts @@ -6,7 +6,12 @@ import { Request, Response } from 'express'; import Joi from 'joi'; -import AuthService, { RegistrationClosedError } from '@Services/AuthService'; +import AuthService, { + MFA_CHALLENGE_TTL_MS, + MfaChallengeError, + RegistrationClosedError, + TwoFactorLockedError, +} from '@Services/AuthService'; import SessionService from '@Services/SessionService'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; @@ -38,8 +43,9 @@ export const AuthSchemas = { Username: Joi.string().trim().min(1).max(255).required(), Password: Joi.string().min(1).max(1024).required(), }), + // SEC-C3: the user comes from the mfa_pending cookie only. A legacy + // `UserId` field is stripped and never used. VerifyTwoFactor: Joi.object({ - UserId: Joi.number().integer().positive().required(), Code: Joi.string().trim().min(1).max(64).required(), }), Refresh: Joi.object({ @@ -52,6 +58,10 @@ export const AuthSchemas = { }), }; +/** httpOnly cookie carrying the password-step proof for /verify-2fa. */ +export const MFA_COOKIE_NAME = 'mfa_pending'; +const MFA_COOKIE_PATH = '/api/auth'; + export class AuthController { private readonly AuthService: AuthService; private readonly SessionService: SessionService; @@ -85,6 +95,24 @@ export class AuthController { }); } + /** + * Set / clear the short-lived mfa_pending cookie (SEC-C3) + */ + private SetMfaCookie(res: Response, token: string): void { + const isProduction = this.Config.IsProduction(); + res.cookie(MFA_COOKIE_NAME, token, { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? 'strict' : 'lax', + maxAge: MFA_CHALLENGE_TTL_MS, + path: MFA_COOKIE_PATH, + }); + } + + private ClearMfaCookie(res: Response): void { + res.clearCookie(MFA_COOKIE_NAME, { path: MFA_COOKIE_PATH }); + } + /** * Clear authentication cookies */ @@ -196,11 +224,13 @@ export class AuthController { const userData = result.User.toJSON(); - if (result.TwoFactorRequired) { + if (result.TwoFactorRequired && result.MfaToken) { + // No user id in the body: the second step is bound to this cookie. + this.SetMfaCookie(res, result.MfaToken); ResponseHelper.Success(res, 'Two-factor verification required', { TwoFactorRequired: true, - UserId: userData.Id, Username: userData.Username, + ExpiresInSeconds: Math.floor(MFA_CHALLENGE_TTL_MS / 1000), }); return; } @@ -232,23 +262,22 @@ export class AuthController { */ public VerifyTwoFactor = async (req: Request, res: Response): Promise => { try { - const { UserId, Code } = req.body; + const { Code } = req.body as { Code: string }; + const mfaToken = (req.cookies as Record | undefined)?.[MFA_COOKIE_NAME]; - if (!UserId || !Code) { - ResponseHelper.ValidationError(res, 'Missing required fields', { - UserId: !UserId ? 'UserId is required' : '', - Code: !Code ? 'Two-factor code is required' : '', - }); + if (typeof mfaToken !== 'string' || mfaToken.length === 0 || mfaToken.length > 256) { + ResponseHelper.Unauthorized(res, 'Two-factor session expired. Please sign in again.'); return; } // Extract device info const deviceInfo = this.ExtractDeviceInfo(req); - const result = await this.AuthService.CompleteTwoFactorLogin(UserId, Code, deviceInfo); + const result = await this.AuthService.CompleteTwoFactorLogin(mfaToken, Code, deviceInfo); const userData = result.User.toJSON(); + this.ClearMfaCookie(res); this.SetAuthCookies(res, result.Tokens.AccessToken, result.Tokens.RefreshToken); ResponseHelper.Success(res, 'Login successful', { @@ -264,8 +293,17 @@ export class AuthController { SessionId: result.SessionId, }); } catch (error) { - Logger.Error('Two-factor verification failed', error as Error); - ResponseHelper.Unauthorized(res, (error as Error).message); + if (error instanceof TwoFactorLockedError) { + this.ClearMfaCookie(res); + ResponseHelper.Error(res, error.message, undefined, 429); + return; + } + if (error instanceof MfaChallengeError) { + this.ClearMfaCookie(res); + ResponseHelper.Unauthorized(res, error.message); + return; + } + ResponseHelper.Unauthorized(res, 'Invalid two-factor code'); } }; diff --git a/src/Database/MigrationRunner.ts b/src/Database/MigrationRunner.ts index 973c852..f0679a5 100644 --- a/src/Database/MigrationRunner.ts +++ b/src/Database/MigrationRunner.ts @@ -29,6 +29,7 @@ import * as Migration018 from '@Migrations/018_create_notification_channels'; import * as Migration019 from '@Migrations/019_create_project_notification_subscriptions'; import * as Migration020 from '@Migrations/020_drop_user_notification_columns'; import * as Migration021 from '@Migrations/021_widen_deployments_text_columns'; +import * as Migration022 from '@Migrations/022_create_mfa_challenges_and_2fa_lockout'; import * as Migration999 from '@Migrations/999_migrate_pending_deployments'; interface IMigration { name: string; @@ -131,6 +132,13 @@ export class MigrationRunner { up: Migration021.up, down: Migration021.down, }, + { + // v3.0.1 SEC-C3 — MfaChallenges + TwoFactorAuth lockout columns. + // (010, 011, 014, 015 stay reserved for v3.1.) + name: '022_create_mfa_challenges_and_2fa_lockout', + up: Migration022.up, + down: Migration022.down, + }, { // v3.0 F-001 — one-shot: re-enqueue v2.1 pending deployments into BullMQ. // Idempotent via QueueJobId IS NULL guard. Runs ONCE per env. diff --git a/src/Migrations/022_create_mfa_challenges_and_2fa_lockout.ts b/src/Migrations/022_create_mfa_challenges_and_2fa_lockout.ts new file mode 100644 index 0000000..3982776 --- /dev/null +++ b/src/Migrations/022_create_mfa_challenges_and_2fa_lockout.ts @@ -0,0 +1,101 @@ +/** + * Migration 022 (v3.0.1 — SEC-C3): bind the 2FA step to the password step. + * + * - MfaChallenges: hashed single-use tokens issued after a correct password + * for accounts with 2FA enabled (5-minute lifetime). + * - TwoFactorAuth.FailedAttempts / LockedUntil: per-account lockout. + * + * Idempotent: every step checks the current schema first, so re-running on a + * database that already has the objects (e.g. created by the baseline) is a + * no-op. Numbers 010, 011, 014 and 015 stay reserved for v3.1. + */ + +import { DataTypes, QueryInterface, QueryTypes } from 'sequelize'; +import Logger from '@Utils/Logger'; + +const CHALLENGES = 'MfaChallenges'; +const TWO_FACTOR = 'TwoFactorAuth'; + +async function tableExists(queryInterface: QueryInterface, table: string): Promise { + const tables = (await queryInterface.showAllTables()) as unknown[]; + return tables.some((t) => { + const name = typeof t === 'string' ? t : (t as { tableName?: string }).tableName; + return typeof name === 'string' && name.toLowerCase() === table.toLowerCase(); + }); +} + +export const up = async (queryInterface: QueryInterface): Promise => { + if (!(await tableExists(queryInterface, CHALLENGES))) { + await queryInterface.createTable(CHALLENGES, { + Id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, primaryKey: true }, + UserId: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + references: { model: 'Users', key: 'Id' }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + TokenHash: { type: DataTypes.STRING(64), allowNull: false }, + ExpiresAt: { type: DataTypes.DATE, allowNull: false }, + ConsumedAt: { type: DataTypes.DATE, allowNull: true }, + Attempts: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, defaultValue: 0 }, + IpAddress: { type: DataTypes.STRING(64), allowNull: true }, + CreatedAt: { type: DataTypes.DATE, allowNull: false }, + UpdatedAt: { type: DataTypes.DATE, allowNull: false }, + }); + await queryInterface.addIndex(CHALLENGES, ['TokenHash'], { + name: 'idx_mfa_challenges_token_hash', + unique: true, + }); + await queryInterface.addIndex(CHALLENGES, ['UserId'], { name: 'idx_mfa_challenges_user_id' }); + await queryInterface.addIndex(CHALLENGES, ['ExpiresAt'], { + name: 'idx_mfa_challenges_expires_at', + }); + Logger.Info(`Migration 022: ${CHALLENGES} table created`); + } else { + Logger.Info(`Migration 022: ${CHALLENGES} already exists, skipping`); + } + + if (!(await tableExists(queryInterface, TWO_FACTOR))) { + // Pre-baseline fresh install: the model-derived table will include the + // columns when it is created. + Logger.Info(`Migration 022: ${TWO_FACTOR} not present yet, skipping lockout columns`); + return; + } + + const columns = (await queryInterface.describeTable(TWO_FACTOR)) as Record; + if (!columns.FailedAttempts) { + await queryInterface.addColumn(TWO_FACTOR, 'FailedAttempts', { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + }); + } + if (!columns.LockedUntil) { + await queryInterface.addColumn(TWO_FACTOR, 'LockedUntil', { + type: DataTypes.DATE, + allowNull: true, + }); + } + Logger.Info(`Migration 022: ${TWO_FACTOR} lockout columns ensured`); +}; + +export const down = async (queryInterface: QueryInterface): Promise => { + if (await tableExists(queryInterface, TWO_FACTOR)) { + const columns = (await queryInterface.describeTable(TWO_FACTOR)) as Record; + // Raw ALTER TABLE instead of removeColumn: see CLAUDE.md (Sequelize 6.37 + + // mariadb driver formatResults bug). + for (const column of ['LockedUntil', 'FailedAttempts']) { + if (columns[column]) { + await queryInterface.sequelize.query( + `ALTER TABLE \`${TWO_FACTOR}\` DROP COLUMN \`${column}\``, + { type: QueryTypes.RAW } + ); + } + } + } + if (await tableExists(queryInterface, CHALLENGES)) { + await queryInterface.dropTable(CHALLENGES); + } + Logger.Info('Migration 022: rolled back'); +}; diff --git a/src/Models/MfaChallenge.ts b/src/Models/MfaChallenge.ts new file mode 100644 index 0000000..eedc1b0 --- /dev/null +++ b/src/Models/MfaChallenge.ts @@ -0,0 +1,107 @@ +/** + * MfaChallenge Model — v3.0.1 / SEC-C3. + * + * A short-lived, single-use proof that the password step of a login succeeded + * for a user with two-factor authentication enabled. The raw token only lives + * in the httpOnly `mfa_pending` cookie; the DB stores its SHA-256 hash. + */ + +import { DataTypes, Model, Optional } from 'sequelize'; +import DatabaseConnection from '@Database/DatabaseConnection'; + +export interface IMfaChallengeAttributes { + Id: number; + UserId: number; + TokenHash: string; + ExpiresAt: Date; + ConsumedAt: Date | null; + Attempts: number; + IpAddress: string | null; + CreatedAt: Date; + UpdatedAt: Date; +} + +export type TMfaChallengeCreation = Optional< + IMfaChallengeAttributes, + 'Id' | 'ConsumedAt' | 'Attempts' | 'IpAddress' | 'CreatedAt' | 'UpdatedAt' +>; + +export class MfaChallenge extends Model { + declare Id: number; + declare UserId: number; + declare TokenHash: string; + declare ExpiresAt: Date; + declare ConsumedAt: Date | null; + declare Attempts: number; + declare IpAddress: string | null; + declare readonly CreatedAt: Date; + declare readonly UpdatedAt: Date; +} + +MfaChallenge.init( + { + Id: { + type: DataTypes.INTEGER.UNSIGNED, + autoIncrement: true, + primaryKey: true, + field: 'Id', + }, + UserId: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + field: 'UserId', + }, + TokenHash: { + type: DataTypes.STRING(64), + allowNull: false, + field: 'TokenHash', + }, + ExpiresAt: { + type: DataTypes.DATE, + allowNull: false, + field: 'ExpiresAt', + }, + ConsumedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'ConsumedAt', + }, + Attempts: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + field: 'Attempts', + }, + IpAddress: { + type: DataTypes.STRING(64), + allowNull: true, + field: 'IpAddress', + }, + CreatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'CreatedAt', + }, + UpdatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'UpdatedAt', + }, + }, + { + sequelize: DatabaseConnection.GetInstance(), + tableName: 'MfaChallenges', + timestamps: true, + createdAt: 'CreatedAt', + updatedAt: 'UpdatedAt', + indexes: [ + { name: 'idx_mfa_challenges_token_hash', unique: true, fields: ['TokenHash'] }, + { name: 'idx_mfa_challenges_user_id', fields: ['UserId'] }, + { name: 'idx_mfa_challenges_expires_at', fields: ['ExpiresAt'] }, + ], + } +); + +export default MfaChallenge; diff --git a/src/Models/TwoFactorAuth.ts b/src/Models/TwoFactorAuth.ts index 5865477..f17e65a 100644 --- a/src/Models/TwoFactorAuth.ts +++ b/src/Models/TwoFactorAuth.ts @@ -18,6 +18,8 @@ export class TwoFactorAuth extends Model { declare RecoveryEmail?: string | null; declare EnabledAt?: Date | null; declare LastUsedAt?: Date | null; + declare FailedAttempts: number; + declare LockedUntil?: Date | null; declare readonly CreatedAt: Date; declare readonly UpdatedAt: Date; } @@ -79,6 +81,18 @@ TwoFactorAuth.init( allowNull: true, field: 'LastUsedAt', }, + // v3.0.1 SEC-C3 — per-account second-factor lockout. + FailedAttempts: { + type: DataTypes.INTEGER.UNSIGNED, + allowNull: false, + defaultValue: 0, + field: 'FailedAttempts', + }, + LockedUntil: { + type: DataTypes.DATE, + allowNull: true, + field: 'LockedUntil', + }, CreatedAt: { type: DataTypes.DATE, allowNull: false, diff --git a/src/Models/index.ts b/src/Models/index.ts index a9bb5dd..38b6753 100644 --- a/src/Models/index.ts +++ b/src/Models/index.ts @@ -21,6 +21,7 @@ import NotificationChannel from './NotificationChannel'; // v3.0 F-006 import ProjectNotificationSubscription from './ProjectNotificationSubscription'; // v3.0 F-006 import Workspace from './Workspace'; // v3.0 F-009 import ProjectTemplate from './ProjectTemplate'; // v3.0 F-008 +import MfaChallenge from './MfaChallenge'; // v3.0.1 SEC-C3 /** * Define Model Associations @@ -233,6 +234,7 @@ export { ProjectNotificationSubscription, // v3.0 F-006 Workspace, // v3.0 F-009 ProjectTemplate, // v3.0 F-008 + MfaChallenge, // v3.0.1 SEC-C3 }; /** @@ -256,5 +258,6 @@ export default { ProjectNotificationSubscription, // v3.0 F-006 Workspace, // v3.0 F-009 ProjectTemplate, // v3.0 F-008 + MfaChallenge, // v3.0.1 SEC-C3 InitializeAssociations, }; diff --git a/src/Services/AuthService.ts b/src/Services/AuthService.ts index 3f9528a..3eb2939 100644 --- a/src/Services/AuthService.ts +++ b/src/Services/AuthService.ts @@ -4,9 +4,10 @@ * Following SOLID principles and PascalCase naming convention */ +import crypto from 'crypto'; import jwt from 'jsonwebtoken'; -import { Transaction } from 'sequelize'; -import { User } from '@Models/index'; +import { Op, Transaction } from 'sequelize'; +import { MfaChallenge, User } from '@Models/index'; import PasswordHelper from '@Utils/PasswordHelper'; import AppConfig from '@Config/AppConfig'; import Logger from '@Utils/Logger'; @@ -37,6 +38,35 @@ export interface ILoginResult { Tokens?: IAuthTokens; SessionId?: number; TwoFactorRequired: boolean; + /** Raw single-use token for the `mfa_pending` cookie (2FA accounts only). */ + MfaToken?: string; +} + +/** Lifetime of the password-step proof used by /verify-2fa (SEC-C3). */ +export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000; +/** Wrong codes accepted against one challenge before it is burned. */ +export const MFA_CHALLENGE_MAX_ATTEMPTS = 5; + +/** The mfa_pending token is missing, unknown, expired or already used. */ +export class MfaChallengeError extends Error { + constructor(message: string = 'Two-factor session expired. Please sign in again.') { + super(message); + this.name = 'MfaChallengeError'; + } +} + +/** Too many failed second-factor attempts on this account. */ +export class TwoFactorLockedError extends Error { + public readonly LockedUntil: Date; + constructor(lockedUntil: Date) { + super('Too many failed two-factor attempts. Try again later.'); + this.name = 'TwoFactorLockedError'; + this.LockedUntil = lockedUntil; + } +} + +function HashMfaToken(token: string): string { + return crypto.createHash('sha256').update(token, 'utf8').digest('hex'); } /** @@ -192,12 +222,16 @@ export class AuthService { throw new Error('Invalid credentials'); } - // If 2FA is enabled, require a second step (do not issue tokens yet) + // If 2FA is enabled, require a second step (do not issue tokens yet). + // The password step is proven by a short-lived single-use token bound + // to this user, delivered as an httpOnly cookie (SEC-C3). if (userData.TwoFactorEnabled) { + const mfaToken = await this.CreateMfaChallenge(userData.Id, deviceInfo?.IpAddress); Logger.Info('Two-factor required for user login', { userId: userData.Id }); return { User: user, TwoFactorRequired: true, + MfaToken: mfaToken, }; } @@ -227,42 +261,108 @@ export class AuthService { } /** - * Complete login by verifying 2FA code and issuing tokens + * Persist a new MFA challenge and return the raw token (only its hash is + * stored). Expired challenges older than a day are pruned opportunistically. */ - public async CompleteTwoFactorLogin(userId: number, code: string, deviceInfo?: IDeviceInfo): Promise<{ + private async CreateMfaChallenge(userId: number, ipAddress?: string): Promise { + await MfaChallenge.destroy({ + where: { ExpiresAt: { [Op.lt]: new Date(Date.now() - 24 * 60 * 60 * 1000) } }, + }); + const token = crypto.randomBytes(32).toString('base64url'); + await MfaChallenge.create({ + UserId: userId, + TokenHash: HashMfaToken(token), + ExpiresAt: new Date(Date.now() + MFA_CHALLENGE_TTL_MS), + IpAddress: ipAddress ? ipAddress.slice(0, 64) : null, + }); + return token; + } + + /** + * Complete login by verifying the 2FA code against the challenge issued by + * the password step (SEC-C3). The user is derived from the challenge, never + * from the request body. + * + * Concurrency: the challenge is claimed atomically (ConsumedAt set while + * still NULL) before the code is checked, so only one guess per challenge + * can be in flight. A wrong code releases the claim until + * MFA_CHALLENGE_MAX_ATTEMPTS is reached; a correct one keeps it consumed. + */ + public async CompleteTwoFactorLogin( + mfaToken: string, + code: string, + deviceInfo?: IDeviceInfo + ): Promise<{ User: User; Tokens: IAuthTokens; SessionId: number; }> { + let userId: number | undefined; try { - const user = await User.findByPk(userId); + const challenge = await MfaChallenge.findOne({ + where: { TokenHash: HashMfaToken(mfaToken) }, + }); + if (!challenge || challenge.ConsumedAt || new Date(challenge.ExpiresAt) <= new Date()) { + throw new MfaChallengeError(); + } - if (!user) { - throw new Error('User not found'); + const [claimed] = await MfaChallenge.update( + { ConsumedAt: new Date() }, + { where: { Id: challenge.Id, ConsumedAt: null } } + ); + if (claimed !== 1) { + throw new MfaChallengeError(); } - const userData = user.toJSON(); + userId = challenge.UserId; + const user = await User.findByPk(userId); + if ( + !user || + !user.IsActive || + (user.AccountStatus && user.AccountStatus !== EAccountStatus.Active) || + !user.TwoFactorEnabled + ) { + throw new MfaChallengeError('Two-factor verification is not available for this account'); + } - if (!userData.TwoFactorEnabled) { - throw new Error('Two-factor authentication is not enabled for this user'); + const lockedUntil = await this.TwoFactorService.GetLockedUntil(userId); + if (lockedUntil) { + throw new TwoFactorLockedError(lockedUntil); } const verified = await this.TwoFactorService.VerifyLoginCode(userId, code); - if (!verified) { + const attempts = challenge.Attempts + 1; + const exhausted = attempts >= MFA_CHALLENGE_MAX_ATTEMPTS; + await MfaChallenge.update( + { Attempts: attempts, ConsumedAt: exhausted ? new Date() : null }, + { where: { Id: challenge.Id } } + ); + const locked = await this.TwoFactorService.RecordFailedAttempt(userId); + if (locked) { + const until = await this.TwoFactorService.GetLockedUntil(userId); + throw new TwoFactorLockedError(until ?? new Date()); + } + if (exhausted) { + throw new MfaChallengeError('Too many invalid codes. Please sign in again.'); + } throw new Error('Invalid two-factor code'); } + await this.TwoFactorService.ResetFailedAttempts(userId); + user.set('LastLogin', new Date()); await user.save(); - // Create session for tracking const session = await this.SessionService.CreateSession(userId, deviceInfo); const sessionData = session.toJSON(); const tokens = this.GenerateTokens(user); - Logger.Info('Two-factor verification successful, tokens issued', { userId, sessionId: sessionData.Id }); + Logger.Info('Two-factor verification successful, tokens issued', { + userId, + sessionId: sessionData.Id, + }); return { User: user, @@ -270,7 +370,10 @@ export class AuthService { SessionId: sessionData.Id, }; } catch (error) { - Logger.Error('Failed to complete two-factor login', error as Error, { userId }); + Logger.Warn('Failed to complete two-factor login', { + userId, + reason: (error as Error).message, + }); throw error; } } diff --git a/src/Services/TwoFactorAuthService.ts b/src/Services/TwoFactorAuthService.ts index 622a1ae..6c65231 100644 --- a/src/Services/TwoFactorAuthService.ts +++ b/src/Services/TwoFactorAuthService.ts @@ -3,6 +3,7 @@ * Implements TOTP generation, verification, and backup codes */ +import crypto from 'crypto'; import qrcode from 'qrcode'; import speakeasy from 'speakeasy'; import { TwoFactorAuth, User } from '@Models/index'; @@ -14,6 +15,47 @@ interface ITotpSecret { qrCodeUrl: string; } +/** Number of backup codes issued per (re)generation. */ +export const BACKUP_CODE_COUNT = 10; +/** Random bytes per backup code: 10 bytes = 80 bits of entropy (SEC-C3). */ +const BACKUP_CODE_BYTES = 10; +/** Storage prefix for salted backup-code hashes. Entries without it are legacy. */ +const BACKUP_CODE_HASH_PREFIX = 'v2$'; +/** Crockford base32 alphabet: no I, L, O, U, so codes are easy to read aloud. */ +const BASE32_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/** Consecutive failed second-factor attempts before the account is locked. */ +export const TWO_FACTOR_MAX_FAILURES = 5; +/** Lockout duration after too many failures. */ +export const TWO_FACTOR_LOCKOUT_MS = 15 * 60 * 1000; + +function EncodeBase32(bytes: Buffer): string { + let bits = 0; + let value = 0; + let output = ''; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + return output; +} + +/** Uppercase and drop separators so "abcd-efgh ..." matches "ABCDEFGH...". */ +export function NormalizeBackupCode(code: string): string { + return code.toUpperCase().replace(/[\s-]/g, ''); +} + +function HashBackupCode(normalized: string, salt: Buffer): string { + return crypto.createHash('sha256').update(salt).update(normalized, 'utf8').digest('hex'); +} + export class TwoFactorAuthService { /** * Generate a new TOTP secret and QR code for a user @@ -83,11 +125,8 @@ export class TwoFactorAuthService { verified = true; } - if (!verified && codes.length > 0) { - const hashed = EncryptionHelper.Hash(code.trim()); - if (codes.includes(hashed)) { - verified = true; - } + if (!verified && this.FindBackupCodeIndex(codes, code) !== -1) { + verified = true; } if (!verified) { @@ -126,17 +165,68 @@ export class TwoFactorAuthService { } /** - * Get 2FA status + * Get 2FA status. `legacyBackupCodesInvalidated` is true when the stored + * codes predate v3.0.1: those weak codes are no longer accepted and the + * user should regenerate (SEC-C3). */ - public async GetStatus(userId: number): Promise<{ enabled: boolean; enabledAt?: Date | null }> { + public async GetStatus(userId: number): Promise<{ + enabled: boolean; + enabledAt?: Date | null; + backupCodesRemaining: number; + legacyBackupCodesInvalidated: boolean; + }> { const record = await this.GetOrCreateRecord(userId); const recordData = record.toJSON(); + const all = this.ParseBackupCodes(record); + const current = all.filter((c) => c.startsWith(BACKUP_CODE_HASH_PREFIX)); return { enabled: recordData.IsEnabled, enabledAt: recordData.EnabledAt || null, + backupCodesRemaining: current.length, + legacyBackupCodesInvalidated: recordData.IsEnabled && all.length > current.length, }; } + /** + * Remaining lockout for the account, or null when it is not locked. + */ + public async GetLockedUntil(userId: number): Promise { + const record = await TwoFactorAuth.findOne({ where: { UserId: userId } }); + const lockedUntil = record?.get('LockedUntil') as Date | null | undefined; + if (lockedUntil && new Date(lockedUntil).getTime() > Date.now()) { + return new Date(lockedUntil); + } + return null; + } + + /** + * Count a failed second-factor attempt. After TWO_FACTOR_MAX_FAILURES the + * account is locked for TWO_FACTOR_LOCKOUT_MS. Returns true when this + * failure triggered the lock. + */ + public async RecordFailedAttempt(userId: number): Promise { + const record = await this.GetOrCreateRecord(userId); + await record.increment('FailedAttempts'); + await record.reload(); + const failures = Number(record.get('FailedAttempts') ?? 0); + if (failures >= TWO_FACTOR_MAX_FAILURES) { + record.set('FailedAttempts', 0); + record.set('LockedUntil', new Date(Date.now() + TWO_FACTOR_LOCKOUT_MS)); + await record.save(); + Logger.Warn('Two-factor lockout triggered', { userId }); + return true; + } + return false; + } + + /** Clear the failure counter after a successful second factor. */ + public async ResetFailedAttempts(userId: number): Promise { + await TwoFactorAuth.update( + { FailedAttempts: 0, LockedUntil: null }, + { where: { UserId: userId } } + ); + } + private async GetUserOrThrow(userId: number): Promise { const user = await User.findByPk(userId); if (!user) { @@ -188,28 +278,56 @@ export class TwoFactorAuthService { } // Check backup codes and remove if used - if (codes.length > 0) { - const hashed = EncryptionHelper.Hash(code.trim()); - const index = codes.findIndex((c) => c === hashed); - if (index !== -1) { - codes.splice(index, 1); - record.set('BackupCodes', JSON.stringify(codes)); - await record.save(); - return true; - } + const index = this.FindBackupCodeIndex(codes, code); + if (index !== -1) { + codes.splice(index, 1); + record.set('BackupCodes', JSON.stringify(codes)); + record.set('LastUsedAt', new Date()); + await record.save(); + return true; } return false; } + /** + * Issue BACKUP_CODE_COUNT codes of 80 random bits each, shown to the user + * once as XXXX-XXXX-XXXX-XXXX and stored as salted SHA-256 hashes. + */ private GenerateBackupCodes(): { codes: string[]; hashes: string[] } { - const codes = Array.from({ length: 10 }).map(() => - EncryptionHelper.GenerateRandomString(5).substring(0, 5).toUpperCase() - ); - const hashes = codes.map((c) => EncryptionHelper.Hash(c)); + const codes: string[] = []; + const hashes: string[] = []; + for (let i = 0; i < BACKUP_CODE_COUNT; i += 1) { + const raw = EncodeBase32(crypto.randomBytes(BACKUP_CODE_BYTES)); + const salt = crypto.randomBytes(16); + codes.push(raw.match(/.{1,4}/g)?.join('-') ?? raw); + hashes.push(`${BACKUP_CODE_HASH_PREFIX}${salt.toString('hex')}$${HashBackupCode(raw, salt)}`); + } return { codes, hashes }; } + /** + * Index of the stored hash matching `code`, or -1. Only v3.0.1 salted + * hashes are considered: legacy 20-bit codes are invalidated (SEC-C3). + */ + private FindBackupCodeIndex(storedHashes: string[], code: string): number { + const normalized = NormalizeBackupCode(code); + if (!/^[0-9A-Z]{16}$/.test(normalized)) return -1; + + for (let i = 0; i < storedHashes.length; i += 1) { + const entry = storedHashes[i]; + if (!entry || !entry.startsWith(BACKUP_CODE_HASH_PREFIX)) continue; + const [saltHex, hashHex] = entry.slice(BACKUP_CODE_HASH_PREFIX.length).split('$'); + if (!saltHex || !hashHex) continue; + const expected = Buffer.from(hashHex, 'hex'); + const actual = Buffer.from(HashBackupCode(normalized, Buffer.from(saltHex, 'hex')), 'hex'); + if (expected.length === actual.length && crypto.timingSafeEqual(expected, actual)) { + return i; + } + } + return -1; + } + private ParseBackupCodes(record: TwoFactorAuth): string[] { const recordData = record.toJSON(); if (!recordData.BackupCodes) return []; diff --git a/src/Types/IDatabase.ts b/src/Types/IDatabase.ts index 542302a..571563f 100644 --- a/src/Types/IDatabase.ts +++ b/src/Types/IDatabase.ts @@ -58,6 +58,8 @@ export interface ITwoFactorAuthAttributes { RecoveryEmail?: string | null; EnabledAt?: Date | null; LastUsedAt?: Date | null; + FailedAttempts?: number; + LockedUntil?: Date | null; CreatedAt: Date; UpdatedAt: Date; } From 087901f9aa7e5bac5bd7b60a0b3fe01c7692a178 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 22:59:42 +0000 Subject: [PATCH 05/22] fix(pipeline): evaluate RunIf with a safe parser instead of new Function (ENG-C3) Context values such as the commit message were substituted unescaped into a string that was then executed as JavaScript inside the server process. RunIf is now evaluated by ConditionEvaluator, a tokenizer plus recursive-descent parser that supports the documented syntax only: ==, ===, !=, !==, contains, &&, ||, !, parentheses, string / number / boolean literals, hasVar('X') and variables written as Name, $NAME or {{Name}}. Values are always data and no global is reachable. Anything that cannot be parsed or references an unknown variable fails closed: the step is skipped and a warning is written to the deployment log. Tests cover the syntax, fail-closed inputs and injection payloads (quotes, template strings, newlines). --- .../unit/Utils/ConditionEvaluator.test.ts | 131 +++++++ src/Services/PipelineService.ts | 61 ++-- src/Utils/ConditionEvaluator.ts | 329 ++++++++++++++++++ 3 files changed, 483 insertions(+), 38 deletions(-) create mode 100644 __tests__/unit/Utils/ConditionEvaluator.test.ts create mode 100644 src/Utils/ConditionEvaluator.ts diff --git a/__tests__/unit/Utils/ConditionEvaluator.test.ts b/__tests__/unit/Utils/ConditionEvaluator.test.ts new file mode 100644 index 0000000..bb7c660 --- /dev/null +++ b/__tests__/unit/Utils/ConditionEvaluator.test.ts @@ -0,0 +1,131 @@ +/** + * ConditionEvaluator — v3.0.1 / ENG-C3. + * RunIf must keep its documented syntax, treat context values as inert data, + * and fail closed on anything it cannot parse. + */ + +import fs from 'fs'; +import path from 'path'; +import { EvaluateCondition, MAX_CONDITION_LENGTH } from '@Utils/ConditionEvaluator'; + +const ctx = { + Branch: 'main', + Commit: 'abc1234', + RepoName: 'app', + ProjectPath: '/srv/app', + ENVIRONMENT: 'production', + Environment: 'production', + COMMIT_MESSAGE: 'release: ship it [deploy]', + TRIGGERED_BY: 'webhook', + EMPTY: '', + COUNT: '3', +}; + +const value = (expr: string, context: Record = ctx): boolean | string => { + const result = EvaluateCondition(expr, context); + return result.Ok ? result.Value : `error: ${result.Error}`; +}; + +describe('ConditionEvaluator — supported syntax', () => { + it.each([ + ["Branch == 'main'", true], + ["Branch === 'main'", true], + ['Branch != "main"', false], + ["Branch !== 'dev'", true], + ["$ENVIRONMENT == 'production'", true], + ["$BRANCH == 'main'", true], + ["{{Environment}} === 'production'", true], + ["{{ Environment }} === 'staging'", false], + ["$COMMIT_MESSAGE contains '[deploy]'", true], + ["$BRANCH contains 'release'", false], + ["$ENVIRONMENT == 'production' && $BRANCH == 'main'", true], + ["$ENVIRONMENT == 'staging' || $ENVIRONMENT == 'production'", true], + ["($ENVIRONMENT == 'production' && $BRANCH == 'dev') || $TRIGGERED_BY == 'webhook'", true], + ["!($BRANCH == 'main')", false], + ["hasVar('ENVIRONMENT')", true], + ["hasVar('EMPTY')", false], + ["hasVar('MISSING')", false], + ["hasVar('ENVIRONMENT') && Branch != 'dev'", true], + ['COUNT == 3', true], + ['true', true], + ['false || !false', true], + ['', true], + ])('%s → %s', (expr, expected) => { + expect(value(expr)).toBe(expected); + }); +}); + +describe('ConditionEvaluator — fail closed', () => { + it.each([ + "Branch == 'main", + "Branch == 'main' &&", + "(Branch == 'main'", + "MISSING == 'x'", + "MISSING != 'x'", + "Branch == 'main'; process.exit(1)", + 'process.exit(1)', + "require('child_process')", + "constructor.constructor('return process')()", + 'this', + 'globalThis', + "Branch.constructor", + '`id`', + "$__proto__ == 'x'", + "hasVar(Branch)", + "Branch = 'main'", + "Branch contains", + "{{Branch}", + ])('%s fails closed', (expr) => { + const result = EvaluateCondition(expr, ctx); + expect(result.Ok).toBe(false); + }); + + it('rejects over-long expressions', () => { + const result = EvaluateCondition(`${"'a' == 'a' && ".repeat(MAX_CONDITION_LENGTH)}true`, ctx); + expect(result.Ok).toBe(false); + }); + + it('rejects absurd nesting without overflowing the stack', () => { + const result = EvaluateCondition(`${'('.repeat(200)}true${')'.repeat(200)}`, ctx); + expect(result.Ok).toBe(false); + }); + + it('does not see inherited properties as variables', () => { + expect(value("hasVar('constructor')")).toBe(false); + expect(EvaluateCondition("toString == 'x'", ctx).Ok).toBe(false); + }); +}); + +describe('ConditionEvaluator — injection payloads in context values are inert', () => { + const sentinel = globalThis as unknown as { __runIfPwned?: boolean }; + + beforeEach(() => { + sentinel.__runIfPwned = false; + }); + + it.each([ + 'main"; globalThis.__runIfPwned = true; "', + "main'; globalThis.__runIfPwned = true; '", + '" + (globalThis.__runIfPwned = true) + "', + '${globalThis.__runIfPwned = true}', + "main\n\"); globalThis.__runIfPwned = true; (\"", + '\\"; globalThis.__runIfPwned = true; //', + ])('commit message %j is compared as data', (payload) => { + const context = { ...ctx, Branch: payload, COMMIT_MESSAGE: payload }; + expect(value("Branch == 'main'", context)).toBe(false); + expect(value("$COMMIT_MESSAGE contains 'deploy'", context)).toBe(false); + expect(value(`Branch == ${JSON.stringify(payload)}`, context)).toBe(true); + expect(sentinel.__runIfPwned).toBe(false); + }); +}); + +describe('PipelineService no longer evaluates code', () => { + it('contains no Function constructor or eval', () => { + const source = fs.readFileSync( + path.resolve(__dirname, '../../../src/Services/PipelineService.ts'), + 'utf8' + ); + expect(source).not.toMatch(/new Function\s*\(/); + expect(source).not.toMatch(/\beval\s*\(/); + }); +}); diff --git a/src/Services/PipelineService.ts b/src/Services/PipelineService.ts index 62ff858..132c0d4 100644 --- a/src/Services/PipelineService.ts +++ b/src/Services/PipelineService.ts @@ -8,6 +8,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import os from 'os'; import crypto from 'crypto'; import Logger from '@Utils/Logger'; +import { EvaluateCondition as EvaluateRunIf } from '@Utils/ConditionEvaluator'; import LogFormatter, { LogPhase } from '@Utils/LogFormatter'; import { IDeploymentContext, IPipelineStep } from '@Types/ICommon'; import { DeploymentStep } from '@Models/index'; @@ -86,7 +87,7 @@ export class PipelineService { try { // Check if step should run (conditional execution) if (step.RunIf) { - const shouldRun = this.EvaluateCondition(step.RunIf, context); + const shouldRun = this.EvaluateCondition(step.RunIf, context, deploymentId, step.Name); if (!shouldRun) { Logger.Deployment(`Step ${stepNumber} skipped (condition not met)`, { deploymentId, @@ -376,46 +377,30 @@ export class PipelineService { } /** - * Evaluate conditional expression + * Evaluate a step's RunIf expression with the safe ConditionEvaluator + * (ENG-C3). Context values are data, never code. Anything the evaluator + * cannot parse or resolve fails closed: the step is skipped and the reason + * is written to the deployment log. */ - private EvaluateCondition(expression: string, context: IDeploymentContext): boolean { - try { - // Create a safe evaluation context - const hasVar = (key: string): boolean => { - return context[key] !== undefined && context[key] !== null && context[key] !== ''; - }; - - // Replace variables in expression - let evaluableExpression = expression; - - // Replace hasVar() calls - evaluableExpression = evaluableExpression.replace(/hasVar\(['"](\w+)['"]\)/g, (_, key) => { - return String(hasVar(key)); - }); - - // Replace variable references - Object.keys(context).forEach((key) => { - const value = context[key]; - if (typeof value === 'string') { - evaluableExpression = evaluableExpression.replace( - new RegExp(`\\b${key}\\b`, 'g'), - `"${value}"` - ); - } - }); - - // Evaluate the expression - // Note: Using Function constructor for safe evaluation (limited scope) - const result = new Function(`return ${evaluableExpression}`)(); + private EvaluateCondition( + expression: string, + context: IDeploymentContext, + deploymentId?: number, + stepName?: string + ): boolean { + const result = EvaluateRunIf(expression, context); + if (result.Ok) { + return result.Value; + } - return Boolean(result); - } catch (error) { - Logger.Warn('Failed to evaluate condition, defaulting to false', { - expression, - error: (error as Error).message, - }); - return false; + const message = + `RunIf for step "${stepName ?? 'unnamed'}" could not be evaluated (${result.Error}); ` + + 'the step is skipped (fail closed).'; + Logger.Warn(message, { deploymentId, expression }); + if (deploymentId) { + SocketService.GetInstance().EmitDeploymentLog(deploymentId, `[WARN] ${message}`); } + return false; } /** diff --git a/src/Utils/ConditionEvaluator.ts b/src/Utils/ConditionEvaluator.ts new file mode 100644 index 0000000..a474624 --- /dev/null +++ b/src/Utils/ConditionEvaluator.ts @@ -0,0 +1,329 @@ +/** + * ConditionEvaluator — safe evaluator for pipeline `RunIf` expressions + * (v3.0.1, ENG-C3). Replaces `new Function(...)`. + * + * Context values (branch, commit message, author…) are attacker-influenced, + * so they are only ever treated as data: the expression is tokenized and + * parsed by a tiny recursive-descent parser; nothing is executed and no + * global is reachable. + * + * Grammar: + * expr := or + * or := and ( '||' and )* + * and := unary ( '&&' unary )* + * unary := '!' unary | compare + * compare := primary ( ( '==' | '===' | '!=' | '!==' | 'contains' ) primary )? + * primary := '(' expr ')' | STRING | NUMBER | 'true' | 'false' + * | 'hasVar' '(' STRING ')' | VARIABLE + * VARIABLE:= IDENT | '$' IDENT | '{{' IDENT '}}' + * + * Variables are looked up exactly first, then case- and underscore- + * insensitively (so `$BRANCH` finds `Branch`). An unknown variable, an + * unknown token or any syntax error is an evaluation failure, and callers + * must fail closed (skip the step). + */ + +export type TConditionContext = Record; + +export type TConditionResult = { Ok: true; Value: boolean } | { Ok: false; Error: string }; + +type TValue = string | number | boolean; + +type TToken = + | { Kind: 'string'; Value: string } + | { Kind: 'number'; Value: number } + | { Kind: 'ident'; Value: string } + | { Kind: 'var'; Value: string } + | { Kind: 'op'; Value: string } + | { Kind: 'end' }; + +export const MAX_CONDITION_LENGTH = 1000; +const MAX_DEPTH = 32; +const IDENT = /[A-Za-z_][A-Za-z0-9_]*/y; +const NUMBER = /[0-9]+(?:\.[0-9]+)?/y; +const OPERATORS = ['===', '!==', '==', '!=', '&&', '||', '!', '(', ')']; + +class ConditionError extends Error {} + +function Tokenize(input: string): TToken[] { + const tokens: TToken[] = []; + let i = 0; + + const readIdent = (at: number): string => { + IDENT.lastIndex = at; + const match = IDENT.exec(input); + if (!match) throw new ConditionError(`Expected identifier at position ${at}`); + return match[0]; + }; + + while (i < input.length) { + const ch = input[i] as string; + + if (/\s/.test(ch)) { + i += 1; + continue; + } + + if (ch === "'" || ch === '"') { + let value = ''; + let j = i + 1; + let closed = false; + while (j < input.length) { + const c = input[j] as string; + if (c === '\\') { + const next = input[j + 1]; + if (next === undefined) break; + value += next === 'n' ? '\n' : next === 't' ? '\t' : next === 'r' ? '\r' : next; + j += 2; + continue; + } + if (c === ch) { + closed = true; + j += 1; + break; + } + value += c; + j += 1; + } + if (!closed) throw new ConditionError('Unterminated string literal'); + tokens.push({ Kind: 'string', Value: value }); + i = j; + continue; + } + + if (input.startsWith('{{', i)) { + let j = i + 2; + while (j < input.length && /\s/.test(input[j] as string)) j += 1; + const name = readIdent(j); + j += name.length; + while (j < input.length && /\s/.test(input[j] as string)) j += 1; + if (!input.startsWith('}}', j)) throw new ConditionError('Unterminated {{variable}}'); + tokens.push({ Kind: 'var', Value: name }); + i = j + 2; + continue; + } + + if (ch === '$') { + const name = readIdent(i + 1); + tokens.push({ Kind: 'var', Value: name }); + i += 1 + name.length; + continue; + } + + NUMBER.lastIndex = i; + const num = NUMBER.exec(input); + if (num) { + tokens.push({ Kind: 'number', Value: Number(num[0]) }); + i += num[0].length; + continue; + } + + IDENT.lastIndex = i; + const ident = IDENT.exec(input); + if (ident) { + tokens.push({ Kind: 'ident', Value: ident[0] }); + i += ident[0].length; + continue; + } + + const op = OPERATORS.find((candidate) => input.startsWith(candidate, i)); + if (op) { + tokens.push({ Kind: 'op', Value: op }); + i += op.length; + continue; + } + + throw new ConditionError(`Unexpected character '${ch}' at position ${i}`); + } + + tokens.push({ Kind: 'end' }); + return tokens; +} + +function Normalize(name: string): string { + return name.replace(/_/g, '').toLowerCase(); +} + +function Truthy(value: TValue): boolean { + if (typeof value === 'string') return value.length > 0; + if (typeof value === 'number') return value !== 0; + return value; +} + +function LooseEquals(a: TValue, b: TValue): boolean { + if (typeof a === typeof b) return a === b; + return String(a) === String(b); +} + +class Parser { + private position = 0; + private depth = 0; + + constructor( + private readonly tokens: TToken[], + private readonly context: TConditionContext + ) {} + + public Parse(): TValue { + const value = this.Or(); + if (this.Peek().Kind !== 'end') { + throw new ConditionError('Unexpected trailing input'); + } + return value; + } + + private Peek(): TToken { + return this.tokens[this.position] ?? { Kind: 'end' }; + } + + private Next(): TToken { + const token = this.Peek(); + this.position += 1; + return token; + } + + private IsOp(value: string): boolean { + const token = this.Peek(); + return token.Kind === 'op' && token.Value === value; + } + + private Expect(value: string): void { + if (!this.IsOp(value)) throw new ConditionError(`Expected '${value}'`); + this.position += 1; + } + + private Enter(): void { + this.depth += 1; + if (this.depth > MAX_DEPTH) throw new ConditionError('Expression is nested too deeply'); + } + + private Or(): TValue { + let left = this.And(); + while (this.IsOp('||')) { + this.position += 1; + const right = this.And(); + left = Truthy(left) || Truthy(right); + } + return left; + } + + private And(): TValue { + let left = this.Unary(); + while (this.IsOp('&&')) { + this.position += 1; + const right = this.Unary(); + left = Truthy(left) && Truthy(right); + } + return left; + } + + private Unary(): TValue { + if (this.IsOp('!')) { + this.position += 1; + this.Enter(); + const value = !Truthy(this.Unary()); + this.depth -= 1; + return value; + } + return this.Compare(); + } + + private Compare(): TValue { + const left = this.Primary(); + const token = this.Peek(); + + if (token.Kind === 'op' && ['==', '===', '!=', '!=='].includes(token.Value)) { + this.position += 1; + const right = this.Primary(); + const equal = LooseEquals(left, right); + return token.Value.startsWith('!') ? !equal : equal; + } + + if (token.Kind === 'ident' && token.Value === 'contains') { + this.position += 1; + const right = this.Primary(); + return String(left).includes(String(right)); + } + + return left; + } + + private Primary(): TValue { + const token = this.Next(); + switch (token.Kind) { + case 'string': + case 'number': + return token.Value; + case 'var': + return this.Resolve(token.Value); + case 'ident': { + if (token.Value === 'true') return true; + if (token.Value === 'false') return false; + if (token.Value === 'hasVar') { + this.Expect('('); + const name = this.Next(); + if (name.Kind !== 'string') throw new ConditionError('hasVar() expects a quoted name'); + this.Expect(')'); + const value = this.Lookup(name.Value); + return value !== undefined && value !== null && value !== ''; + } + if (token.Value === 'contains') throw new ConditionError("Unexpected 'contains'"); + return this.Resolve(token.Value); + } + case 'op': + if (token.Value === '(') { + this.Enter(); + const value = this.Or(); + this.Expect(')'); + this.depth -= 1; + return value; + } + throw new ConditionError(`Unexpected '${token.Value}'`); + default: + throw new ConditionError('Unexpected end of expression'); + } + } + + private Lookup(name: string): string | undefined { + if (Object.prototype.hasOwnProperty.call(this.context, name)) { + return this.context[name]; + } + const wanted = Normalize(name); + const key = Object.keys(this.context).find((candidate) => Normalize(candidate) === wanted); + return key === undefined ? undefined : this.context[key]; + } + + private Resolve(name: string): string { + const value = this.Lookup(name); + if (value === undefined || value === null) { + throw new ConditionError(`Unknown variable '${name}'`); + } + return String(value); + } +} + +/** + * Evaluate a RunIf expression against string context values. Never throws. + */ +export function EvaluateCondition(expression: string, context: TConditionContext): TConditionResult { + if (typeof expression !== 'string') { + return { Ok: false, Error: 'Expression must be a string' }; + } + if (expression.length > MAX_CONDITION_LENGTH) { + return { Ok: false, Error: `Expression longer than ${MAX_CONDITION_LENGTH} characters` }; + } + if (expression.trim() === '') { + return { Ok: true, Value: true }; + } + try { + const value = new Parser(Tokenize(expression), context).Parse(); + return { Ok: true, Value: Truthy(value) }; + } catch (error) { + if (error instanceof ConditionError) { + return { Ok: false, Error: error.message }; + } + return { Ok: false, Error: 'Expression could not be evaluated' }; + } +} + +export default { EvaluateCondition }; From 4d74516663301eb226785b6e9f0938e534340cd5 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:08:37 +0000 Subject: [PATCH 06/22] fix(deploy): run git, rsync, chown and ssh-keygen without a shell (ENG-C2) - New Utils/SafeExec: RunFile wraps execFile with argument arrays and shell:false. Every git / rsync / chown / ssh-keygen call in DeploymentService, SshKeyManager, SshKeyGenerator and AutoRecovery now uses it, with -- before repository URLs, refs and paths. - Strict validation, enforced in CreateDeployment, at clone time and when a project is created or updated (400 on failure): - commit: ^[0-9a-f]{7,40}$ - branch: git check-ref-format --branch, plus a conservative charset because the branch is also substituted into pipeline commands - RepoUrl: URL parser with an https / ssh / git@host:path allowlist - RsyncOptions: allowlisted flags only (no -e, --rsh, --rsync-path) - filesystem paths may not start with - or contain control characters - Repository credentials are stripped from clone commands written to deployment logs. - SecurityMiddleware's regex filter stays as defence in depth; the new tests run without it and cover shell metacharacters, option injection and newline payloads. --- .../integration/CommandInjection.test.ts | 143 ++++++++ __tests__/unit/Utils/SafeExec.test.ts | 192 ++++++++++ src/Services/DeploymentService.ts | 180 +++++++--- src/Services/ProjectService.ts | 34 ++ src/Utils/AutoRecovery.ts | 39 +- src/Utils/SafeExec.ts | 332 ++++++++++++++++++ src/Utils/SshKeyGenerator.ts | 48 ++- src/Utils/SshKeyManager.ts | 22 +- 8 files changed, 892 insertions(+), 98 deletions(-) create mode 100644 __tests__/integration/CommandInjection.test.ts create mode 100644 __tests__/unit/Utils/SafeExec.test.ts create mode 100644 src/Utils/SafeExec.ts diff --git a/__tests__/integration/CommandInjection.test.ts b/__tests__/integration/CommandInjection.test.ts new file mode 100644 index 0000000..82e9cfd --- /dev/null +++ b/__tests__/integration/CommandInjection.test.ts @@ -0,0 +1,143 @@ +/** + * Command injection through the API — v3.0.1 / ENG-C2. + * + * The SecurityMiddleware regex filter is NOT mounted here on purpose: these + * tests prove the service-level validation alone rejects hostile refs, repo + * URLs and rsync options. + */ + +import request from 'supertest'; +import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; +import { makeProject, makeUser } from '../helpers/factories'; +import { authHeader } from '../helpers/token'; +import { buildTestApp } from '../helpers/testApp'; +import DeploymentRoutes from '@Routes/DeploymentRoutes'; +import ProjectRoutes from '@Routes/ProjectRoutes'; +import QueueService from '@Services/QueueService'; +import { Deployment, Project } from '@Models/index'; + +async function dbReachable(): Promise { + try { + await setupTestDb(); + return true; + } catch { + return false; + } +} + +const BAD_BRANCHES = [ + 'main; touch /tmp/dc-pwned', + 'main\ntouch /tmp/dc-pwned', + '$(touch /tmp/dc-pwned)', + '--upload-pack=touch /tmp/dc-pwned', + 'a..b', +]; + +const BAD_COMMITS = [ + 'abc1234; touch /tmp/dc-pwned', + 'abc1234\ntouch /tmp/dc-pwned', + '--orphan=x', + 'HEAD~1', + 'ZZZZZZZ', +]; + +describe('Command injection — ENG-C2', () => { + let dbUp = false; + let app: import('express').Application; + + beforeAll(async () => { + dbUp = await dbReachable(); + }); + + afterAll(async () => { + if (dbUp) await teardownTestDb(); + }); + + beforeEach(async () => { + if (!dbUp) return; + await truncateAll(); + // Fresh routers per test → fresh in-memory DeploymentLimiter. + app = buildTestApp([ + { path: '/api/deployments', router: new DeploymentRoutes().Router }, + { path: '/api/projects', router: new ProjectRoutes().Router }, + ]); + jest.spyOn(QueueService.GetInstance(), 'IsReady').mockReturnValue(true); + jest + .spyOn(QueueService.GetInstance(), 'Enqueue') + .mockImplementation(async (id: number) => `dep-${id}`); + }); + + it.each(BAD_BRANCHES)('manual deploy rejects branch %j', async (Branch) => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const project = await makeProject({ CreatedBy: admin.Id }); + const res = await request(app) + .post(`/api/deployments/projects/${project.Id}/deploy`) + .set(authHeader(admin)) + .send({ Branch }); + expect(res.status).toBe(400); + expect(await Deployment.count()).toBe(0); + }); + + it.each(BAD_COMMITS)('manual deploy rejects commit %j', async (CommitHash) => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const project = await makeProject({ CreatedBy: admin.Id }); + const res = await request(app) + .post(`/api/deployments/projects/${project.Id}/deploy`) + .set(authHeader(admin)) + .send({ Branch: 'main', CommitHash }); + expect(res.status).toBe(400); + expect(await Deployment.count()).toBe(0); + }); + + it('manual deploy still accepts a valid branch and commit', async () => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const project = await makeProject({ CreatedBy: admin.Id }); + const res = await request(app) + .post(`/api/deployments/projects/${project.Id}/deploy`) + .set(authHeader(admin)) + .send({ Branch: 'feature/login', CommitHash: 'abc1234' }); + expect(res.status).toBe(201); + expect(await Deployment.count()).toBe(1); + }); + + it.each([ + ['RepoUrl', { RepoUrl: 'ext::sh -c touch% /tmp/dc-pwned' }], + ['RepoUrl', { RepoUrl: 'file:///etc/passwd' }], + ['RepoUrl', { RepoUrl: '--upload-pack=touch /tmp/dc-pwned' }], + ['Branch', { Branch: 'main;id' }], + ['RsyncOptions', { Config: { Branch: 'main', RsyncOptions: '-e "sh -c id"', Pipeline: [], Variables: {}, AutoDeploy: false } }], + ['RsyncOptions', { Config: { Branch: 'main', RsyncOptions: '--rsync-path=id', Pipeline: [], Variables: {}, AutoDeploy: false } }], + ])('project create rejects a hostile %s', async (_field, overrides) => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const res = await request(app) + .post('/api/projects') + .set(authHeader(admin)) + .send({ + Name: 'inj-project', + RepoUrl: 'https://github.com/org/repo.git', + Branch: 'main', + ProjectPath: '/tmp/test/inj', + ProjectType: 'node', + ...overrides, + }); + expect(res.status).toBe(400); + expect(await Project.count({ where: { Name: 'inj-project' } })).toBe(0); + }); + + it('project update rejects a hostile RepoUrl and keeps the old one', async () => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const project = await makeProject({ CreatedBy: admin.Id, RepoUrl: 'git@github.com:org/ok.git' }); + const res = await request(app) + .put(`/api/projects/${project.Id}`) + .set(authHeader(admin)) + .send({ RepoUrl: 'git@github.com:org/ok.git; touch /tmp/dc-pwned' }); + expect(res.status).toBe(400); + await project.reload(); + expect(project.RepoUrl).toBe('git@github.com:org/ok.git'); + }); +}); diff --git a/__tests__/unit/Utils/SafeExec.test.ts b/__tests__/unit/Utils/SafeExec.test.ts new file mode 100644 index 0000000..4a3d0dd --- /dev/null +++ b/__tests__/unit/Utils/SafeExec.test.ts @@ -0,0 +1,192 @@ +/** + * SafeExec — v3.0.1 / ENG-C2. + * Strict validation of refs / URLs / rsync options, and proof that RunFile + * never hands its arguments to a shell. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + AssertBranchName, + AssertCommitHash, + AssertRepoUrl, + AssertSafePath, + CommandValidationError, + ParseRsyncOptions, + RedactUrlCredentials, + RunFile, +} from '@Utils/SafeExec'; +import { SanitizeKeyComment } from '@Utils/SshKeyGenerator'; + +const marker = path.join(os.tmpdir(), `dc-safeexec-pwned-${process.pid}`); + +const INJECTIONS = [ + `main; touch ${marker}`, + `main && touch ${marker}`, + `main | touch ${marker}`, + `main\ntouch ${marker}`, + `main\r\ntouch ${marker}`, + `$(touch ${marker})`, + `\`touch ${marker}\``, + `main' ; touch ${marker} ; '`, + `main" ; touch ${marker} ; "`, + '--upload-pack=touch /tmp/x', + '-oProxyCommand=touch /tmp/x', +]; + +afterEach(() => { + expect(fs.existsSync(marker)).toBe(false); +}); + +afterAll(() => { + if (fs.existsSync(marker)) fs.unlinkSync(marker); +}); + +describe('AssertCommitHash', () => { + it.each(['abc1234', 'a'.repeat(40), '0123456789abcdef0123456789abcdef01234567'])('accepts %s', (h) => { + expect(AssertCommitHash(h)).toBe(h); + }); + + it.each([ + 'abc123', + 'a'.repeat(41), + 'ABC1234', + 'abc1234 ', + 'abc1234\n', + 'abc1234;id', + 'HEAD', + 'main', + '-abc1234', + ...INJECTIONS, + undefined, + 1234567, + ])('rejects %j', (h) => { + expect(() => AssertCommitHash(h)).toThrow(CommandValidationError); + }); +}); + +describe('AssertBranchName', () => { + it.each(['main', 'feature/login', 'release-3.0.1', 'user/sabry/fix_1', 'v3.0'])('accepts %s', async (b) => { + await expect(AssertBranchName(b)).resolves.toBe(b); + }); + + it.each([ + ...INJECTIONS, + '', + 'a..b', + 'feature/', + 'bad~name', + 'bad^name', + 'bad:name', + 'with space', + 'x'.repeat(256), + 'refs/heads/@{-1}', + 'branch.lock', + 'main;id', + 'main|id', + 'feat$(id)', + "it's", + ])('rejects %j', async (b) => { + await expect(AssertBranchName(b)).rejects.toThrow(CommandValidationError); + }); +}); + +describe('AssertRepoUrl', () => { + it.each([ + 'https://github.com/org/repo.git', + 'https://x-access-token:abc@github.com/org/repo.git', + 'ssh://git@github.com/org/repo.git', + 'ssh://git@gitlab.internal:2222/group/repo.git', + 'git@github.com:org/repo.git', + 'deploy@git.example.com:team/app', + ])('accepts %s', (u) => { + expect(AssertRepoUrl(u)).toBe(u); + }); + + it.each([ + 'http://github.com/org/repo.git', + 'file:///etc/passwd', + 'git://github.com/org/repo.git', + 'ext::sh -c touch% /tmp/x', + 'ext::sh', + '/srv/repos/app.git', + './repo', + '--upload-pack=touch /tmp/x', + '-u touch', + 'https://github.com/org/repo.git; touch /tmp/x', + 'https://github.com/org/repo.git\ntouch /tmp/x', + 'git@github.com:-oProxyCommand=x', + 'git@github.com:org/repo.git && id', + 'https://', + 'https://github.com/', + 'github.com:org/repo', + '', + ])('rejects %j', (u) => { + expect(() => AssertRepoUrl(u)).toThrow(CommandValidationError); + }); +}); + +describe('ParseRsyncOptions', () => { + it.each([ + ['-rv --delete --omit-dir-times', ['-rv', '--delete', '--omit-dir-times']], + ['--no-perms --no-owner --no-group --omit-dir-times', ['--no-perms', '--no-owner', '--no-group', '--omit-dir-times']], + ['-avz --chmod=ugo=rwX', ['-avz', '--chmod=ugo=rwX']], + ['', []], + ])('accepts %j', (input, expected) => { + expect(ParseRsyncOptions(input)).toEqual(expected); + }); + + it.each([ + '-e "sh -c id"', + '-e', + '-rve', + '--rsh=ssh', + '--rsync-path=touch /tmp/x', + '--files-from=/etc/passwd', + '-rv; touch /tmp/x', + '-rv $(id)', + '-rv\n--delete', + '--chmod=$(id)', + '--exclude-from=/etc/shadow', + 'not-an-option', + ])('rejects %j', (input) => { + expect(() => ParseRsyncOptions(input)).toThrow(CommandValidationError); + }); +}); + +describe('AssertSafePath', () => { + it('rejects option-like and control-character paths', () => { + expect(() => AssertSafePath('-rf')).toThrow(CommandValidationError); + expect(() => AssertSafePath('/srv/app\n/etc')).toThrow(CommandValidationError); + expect(AssertSafePath('/srv/my app')).toBe('/srv/my app'); + }); +}); + +describe('RunFile', () => { + // Option-like payloads are handled by the validators + `--`; here we prove + // shell metacharacters (and newlines) are never interpreted. + it.each(INJECTIONS.filter((p) => !p.startsWith('-')))( + 'passes %j through as one literal argument', + async (payload) => { + const { stdout } = await RunFile(process.execPath, [ + '-e', + 'process.stdout.write(JSON.stringify(process.argv.slice(1)))', + payload, + ]); + expect(JSON.parse(stdout)).toEqual([payload]); + } + ); +}); + +describe('helpers', () => { + it('redacts URL credentials for logs', () => { + expect(RedactUrlCredentials('https://user:tok@github.com/o/r.git')).toBe('https://github.com/o/r.git'); + expect(RedactUrlCredentials('git@github.com:o/r.git')).toBe('git@github.com:o/r.git'); + }); + + it('sanitizes ssh-keygen comments', () => { + expect(SanitizeKeyComment('deploy-center-my"; touch /tmp/x; "')).not.toMatch(/[";\s/]/); + expect(SanitizeKeyComment('')).toBe('deploy-center'); + }); +}); diff --git a/src/Services/DeploymentService.ts b/src/Services/DeploymentService.ts index 9ef42e1..ad03a0e 100644 --- a/src/Services/DeploymentService.ts +++ b/src/Services/DeploymentService.ts @@ -7,10 +7,17 @@ import fs from 'fs-extra'; import path from 'path'; -import { exec } from 'child_process'; import os from 'os'; -import { promisify } from 'util'; import Logger from '@Utils/Logger'; +import { + RunFile, + AssertBranchName, + AssertCommitHash, + AssertRepoUrl, + AssertSafePath, + ParseRsyncOptions, + RedactUrlCredentials, +} from '@Utils/SafeExec'; import LogFormatter, { LogPhase } from '@Utils/LogFormatter'; import SshKeyManager from '@Utils/SshKeyManager'; import AutoRecovery from '@Utils/AutoRecovery'; @@ -130,8 +137,6 @@ function GetSystemPreservePatterns(selfContained?: boolean): string[] { return systemPreservePatterns.filter((p) => !SELF_CONTAINED_SYNC_PATTERNS.includes(p)); } -const execAsync = promisify(exec); - export interface ICreateDeploymentParams { ProjectId: number; TriggeredBy: string; @@ -191,17 +196,17 @@ export class DeploymentService { * underlying helpers. */ private async RunGit( - command: string, + args: string[], cwd: string, sshKeyContext: Awaited> | null, timeoutMs: number ): Promise<{ stdout: string; stderr: string }> { if (sshKeyContext) { - return SshKeyManager.ExecuteGitCommandWithKey(command, sshKeyContext.keyPath, cwd, { + return SshKeyManager.ExecuteGitCommandWithKey(args, sshKeyContext.keyPath, cwd, { timeout: timeoutMs, }); } - return execAsync(command, { cwd, timeout: timeoutMs }); + return RunFile('git', args, { cwd, timeout: timeoutMs }); } /** @@ -236,7 +241,7 @@ export class DeploymentService { try { // Clone all refs (no --branch); reference-clone needs full ref set. await this.RunGit( - `git clone --bare ${project.RepoUrl} "${cachePath}"`, + ['clone', '--bare', '--', AssertRepoUrl(project.RepoUrl), cachePath], cacheParent, sshKeyContext, 300000 // 5 min @@ -264,7 +269,7 @@ export class DeploymentService { await this.AppendLog(deployment, log); try { await this.RunGit( - `git -C "${cachePath}" fetch --all --prune`, + ['-C', cachePath, 'fetch', '--all', '--prune'], cacheParent, sshKeyContext, 300000 @@ -452,6 +457,14 @@ export class DeploymentService { params.CommitMessage || params.WebhookData?.CommitMessage || 'Manual deployment'; const author = params.Author || params.WebhookData?.Author || params.TriggeredBy; + // ENG-C2 — reject hostile refs before anything is stored or queued. + // Manual deploys take these from the request body; webhooks from the + // (signed, but repository-controlled) payload. + await AssertBranchName(branch); + if (commitHash !== 'unknown') { + AssertCommitHash(commitHash); + } + const triggerType = params.ManualTrigger ? ETriggerType.Manual : ETriggerType.Webhook; // Create deployment record @@ -677,9 +690,11 @@ export class DeploymentService { for (const projPath of allProjectPaths) { if (await fs.pathExists(projPath)) { try { - await execAsync(`sudo chown -R ${currentUser}:${currentUser} "${projPath}"`, { - timeout: 120000, - }); + await RunFile( + 'sudo', + ['-n', 'chown', '-R', `${currentUser}:${currentUser}`, '--', AssertSafePath(projPath)], + { timeout: 120000 } + ); Logger.Info('Fixed ownership for project path', { path: projPath, user: currentUser }); } catch (error) { Logger.Warn('Could not fix ownership, continuing anyway', { @@ -824,9 +839,18 @@ export class DeploymentService { await this.AppendLog(deployment, fixLog); try { - await execAsync(`sudo chown -R ${currentUser}:${currentUser} "${productionPath}"`, { - timeout: 120000, - }); + await RunFile( + 'sudo', + [ + '-n', + 'chown', + '-R', + `${currentUser}:${currentUser}`, + '--', + AssertSafePath(productionPath), + ], + { timeout: 120000 } + ); const successLog = LogFormatter.Success( LogPhase.SYNC, `Ownership fixed for ${productionPath}` @@ -1202,6 +1226,16 @@ export class DeploymentService { const stepStartTime = Date.now(); try { + // ENG-C2 — every value that reaches git is validated first and passed + // as a separate argument (no shell). Webhook and manual deployments + // both go through here, so this is the last line of defence. + const repoUrl = AssertRepoUrl(project.RepoUrl); + const branch = await AssertBranchName(deployment.Branch); + const commitHash = + deployment.CommitHash && deployment.CommitHash !== 'unknown' + ? AssertCommitHash(deployment.CommitHash) + : null; + // v3.0 F-005 — ensure per-project bare cache exists; if so, the // working clone uses --reference for a much faster checkout. const { useReference, cachePath } = await this.EnsureBareCache( @@ -1212,10 +1246,13 @@ export class DeploymentService { // --depth 1 is incompatible with --reference (shallow refs can't be // referenced). When useReference is true we drop --depth. - const referenceFlags = useReference - ? ` --reference "${cachePath}" --dissociate` - : ' --depth 1'; - const cloneCommand = `git clone --branch ${deployment.Branch}${referenceFlags} ${project.RepoUrl} .`; + const referenceArgs = useReference + ? ['--reference', cachePath, '--dissociate'] + : ['--depth', '1']; + const cloneArgs = ['clone', '--branch', branch, ...referenceArgs, '--', repoUrl, '.']; + // Display-only form for logs, with any URL credentials removed. + const cloneCommand = `git clone --branch ${branch} ${referenceArgs.join(' ')} -- ${RedactUrlCredentials(repoUrl)} .`; + const checkoutArgs = commitHash ? ['checkout', '--quiet', commitHash, '--'] : null; // Check if project uses SSH key authentication if (sshKeyContext) { @@ -1237,36 +1274,36 @@ export class DeploymentService { // Execute git clone with SSH key const { stdout, stderr } = await SshKeyManager.ExecuteGitCommandWithKey( - cloneCommand, + cloneArgs, sshKeyContext.keyPath, workingDir, { timeout: 300000 } // 5 minutes ); // STEP 3: Checkout specific commit if needed, or get current commit hash - if (deployment.CommitHash && deployment.CommitHash !== 'unknown') { + if (checkoutArgs) { const checkoutLog = LogFormatter.Info( LogPhase.CLONE, - `Checking out commit ${deployment.CommitHash}...` + `Checking out commit ${commitHash}...` ); await this.AppendLog(deployment, checkoutLog); await SshKeyManager.ExecuteGitCommandWithKey( - `git checkout ${deployment.CommitHash}`, + checkoutArgs, sshKeyContext.keyPath, workingDir, { timeout: 30000 } ); } else if (deployment.CommitHash === 'unknown') { // Manual deployment: Get the actual commit hash that was cloned - const { stdout: commitHash } = await SshKeyManager.ExecuteGitCommandWithKey( - 'git rev-parse HEAD', + const { stdout: resolvedHash } = await SshKeyManager.ExecuteGitCommandWithKey( + ['rev-parse', 'HEAD'], sshKeyContext.keyPath, workingDir, { timeout: 10000 } ); - const actualCommitHash = commitHash.trim(); + const actualCommitHash = resolvedHash.trim(); // Update deployment with actual commit hash deployment.CommitHash = actualCommitHash; @@ -1357,7 +1394,7 @@ export class DeploymentService { Logger.Info('Using HTTPS authentication for repository clone', { deploymentId: deployment.Id, - command: cloneCommand.replace(project.RepoUrl, '[REDACTED]'), + command: cloneCommand, workingDir, }); @@ -1365,31 +1402,31 @@ export class DeploymentService { const cloneCommandLog = LogFormatter.FormatCommand(cloneCommand, LogPhase.CLONE); await this.AppendLog(deployment, cloneCommandLog); - const { stdout, stderr } = await execAsync(cloneCommand, { + const { stdout, stderr } = await RunFile('git', cloneArgs, { cwd: workingDir, timeout: 300000, // 5 minutes timeout }); // Checkout specific commit if needed, or get current commit hash - if (deployment.CommitHash && deployment.CommitHash !== 'unknown') { + if (checkoutArgs) { const checkoutLog = LogFormatter.Info( LogPhase.CLONE, - `Checking out commit ${deployment.CommitHash}...` + `Checking out commit ${commitHash}...` ); await this.AppendLog(deployment, checkoutLog); - await execAsync(`git checkout ${deployment.CommitHash}`, { + await RunFile('git', checkoutArgs, { cwd: workingDir, timeout: 30000, }); } else if (deployment.CommitHash === 'unknown') { // Manual deployment: Get the actual commit hash that was cloned - const { stdout: commitHash } = await execAsync('git rev-parse HEAD', { + const { stdout: resolvedHash } = await RunFile('git', ['rev-parse', 'HEAD'], { cwd: workingDir, timeout: 10000, }); - const actualCommitHash = commitHash.trim(); + const actualCommitHash = resolvedHash.trim(); // Update deployment with actual commit hash deployment.CommitHash = actualCommitHash; @@ -2417,18 +2454,22 @@ export class DeploymentService { try { // Sanitize exclude patterns to prevent command injection const sanitizedPatterns = this.sanitizePatterns(preservePatterns); - const excludeArgs = - sanitizedPatterns.length > 0 - ? sanitizedPatterns.map((pattern) => `--exclude='${pattern}'`).join(' ') - : ''; + const excludeArgs = sanitizedPatterns.map((pattern) => `--exclude=${pattern}`); // Get custom rsync options from config or use defaults // Changed from '-av' to '-rv' to avoid permission issues (no archive mode) const defaultRsyncOptions = '-rv --delete --omit-dir-times'; const rsyncOptions = projectJson.Config.RsyncOptions || defaultRsyncOptions; - // rsync command: sync source to target with custom options - const rsyncCommand = `rsync ${rsyncOptions} ${excludeArgs} "${syncSourceDir}/" "${targetPath}/"`; + // ENG-C2 — options come from an allow-list; paths follow `--`; no shell. + const rsyncArgs = [ + ...ParseRsyncOptions(rsyncOptions), + ...excludeArgs, + '--', + `${AssertSafePath(syncSourceDir, 'sync source')}/`, + `${AssertSafePath(targetPath, 'sync target')}/`, + ]; + const rsyncCommand = `rsync ${rsyncArgs.join(' ')}`; Logger.Info('Executing rsync for smart sync', { deploymentId: deployment.Id, @@ -2436,7 +2477,7 @@ export class DeploymentService { customOptions: projectJson.Config.RsyncOptions || 'default', }); - const { stdout, stderr } = await execAsync(rsyncCommand, { + const { stdout, stderr } = await RunFile('rsync', rsyncArgs, { timeout: 300000, // 5 minutes timeout }); @@ -2724,14 +2765,25 @@ export class DeploymentService { try { // Sanitize exclude patterns to prevent command injection const sanitizedPatterns = this.sanitizePatterns(preservePatterns); - const excludeArgs = - sanitizedPatterns.length > 0 - ? sanitizedPatterns.map((pattern) => `--exclude='${pattern}'`).join(' ') - : ''; + const excludeArgs = sanitizedPatterns.map((pattern) => `--exclude=${pattern}`); // Use rsync with relaxed permissions and error handling - const rsyncCommand = `rsync -r --times --omit-dir-times --ignore-errors --no-perms --chmod=ugo=rwX ${excludeArgs} "${productionPath}/" "${backupPath}/"`; - await execAsync(rsyncCommand, { timeout: 300000 }); + await RunFile( + 'rsync', + [ + '-r', + '--times', + '--omit-dir-times', + '--ignore-errors', + '--no-perms', + '--chmod=ugo=rwX', + ...excludeArgs, + '--', + `${AssertSafePath(productionPath)}/`, + `${AssertSafePath(backupPath)}/`, + ], + { timeout: 300000 } + ); } catch (rsyncError) { // If rsync fails, fallback to fs.copy Logger.Warn('rsync backup encountered errors, using fs.copy fallback', { @@ -2787,17 +2839,28 @@ export class DeploymentService { if (process.platform !== 'win32') { // Sanitize exclude patterns to prevent command injection const sanitizedPatterns = this.sanitizePatterns(preservePatterns); - const excludeArgs = - sanitizedPatterns.length > 0 - ? sanitizedPatterns.map((pattern) => `--exclude='${pattern}'`).join(' ') - : ''; - // Build exclude arguments to protect preserved files (.env, uploads, node_modules, etc.) // Without these excludes, --delete would remove files that were excluded from the backup - const rsyncCommand = `rsync -r --delete --times --omit-dir-times --ignore-errors --no-perms --chmod=ugo=rwX ${excludeArgs} "${backupPath}/" "${productionPath}/"`; + const excludeArgs = sanitizedPatterns.map((pattern) => `--exclude=${pattern}`); try { - await execAsync(rsyncCommand, { timeout: 300000 }); + await RunFile( + 'rsync', + [ + '-r', + '--delete', + '--times', + '--omit-dir-times', + '--ignore-errors', + '--no-perms', + '--chmod=ugo=rwX', + ...excludeArgs, + '--', + `${AssertSafePath(backupPath)}/`, + `${AssertSafePath(productionPath)}/`, + ], + { timeout: 300000 } + ); } catch (rsyncError) { // If rsync fails, log but try fallback method Logger.Warn('rsync rollback encountered errors, attempting fallback copy', { @@ -2985,14 +3048,19 @@ export class DeploymentService { sshKeyContext: Awaited> | null ): Promise { try { - const lsRemoteCommand = `git ls-remote ${project.RepoUrl} ${branch}`; + const lsRemoteArgs = [ + 'ls-remote', + '--', + AssertRepoUrl(project.RepoUrl), + await AssertBranchName(branch), + ]; let stdout: string; if (sshKeyContext) { // Use SSH key for authentication const result = await SshKeyManager.ExecuteGitCommandWithKey( - lsRemoteCommand, + lsRemoteArgs, sshKeyContext.keyPath, process.cwd(), { timeout: 30000 } @@ -3000,7 +3068,7 @@ export class DeploymentService { stdout = result.stdout; } else { // Use HTTPS - const result = await execAsync(lsRemoteCommand, { + const result = await RunFile('git', lsRemoteArgs, { timeout: 30000, }); stdout = result.stdout; diff --git a/src/Services/ProjectService.ts b/src/Services/ProjectService.ts index cb460af..ed310e6 100644 --- a/src/Services/ProjectService.ts +++ b/src/Services/ProjectService.ts @@ -10,6 +10,12 @@ import { Project, ProjectMember, User } from '@Models/index'; import Logger from '@Utils/Logger'; import EncryptionHelper, { type IEncryptedData } from '@Utils/EncryptionHelper'; import SshKeyGenerator from '@Utils/SshKeyGenerator'; +import { + AssertBranchName, + AssertRepoUrl, + AssertSafePath, + ParseRsyncOptions, +} from '@Utils/SafeExec'; import AuditLogService from '@Services/AuditLogService'; import AppConfig from '@Config/AppConfig'; import { IProjectConfigJson } from '@Types/IDatabase'; @@ -40,6 +46,30 @@ export interface IUpdateProjectData { } export class ProjectService { + /** + * ENG-C2 — validate every project field that later reaches git / rsync / + * chown, so bad values are rejected with 400 at save time instead of + * failing (or worse) at deploy time. Only fields present are checked. + */ + public static async ValidateExecutionInputs(data: { + RepoUrl?: string; + Branch?: string; + ProjectPath?: string; + DeploymentPaths?: string[]; + Config?: Partial; + }): Promise { + if (data.RepoUrl !== undefined) AssertRepoUrl(data.RepoUrl); + if (data.Branch !== undefined) await AssertBranchName(data.Branch); + if (data.ProjectPath) AssertSafePath(data.ProjectPath, 'project path'); + if (Array.isArray(data.DeploymentPaths)) { + data.DeploymentPaths.forEach((p) => AssertSafePath(p, 'deployment path')); + } + if (data.Config) { + if (data.Config.Branch) await AssertBranchName(data.Config.Branch); + ParseRsyncOptions(data.Config.RsyncOptions); + } + } + /** * Get all projects (filtered by user role and membership) * - Admin/Manager: See all projects @@ -117,6 +147,8 @@ export class ProjectService { */ public async CreateProject(data: ICreateProjectData, req: Request): Promise { try { + await ProjectService.ValidateExecutionInputs(data); + // Check if project with same name exists const existingProject = await this.GetProjectByName(data.Name); if (existingProject) { @@ -178,6 +210,8 @@ export class ProjectService { throw new Error('Project not found'); } + await ProjectService.ValidateExecutionInputs(data); + // Check if name is being changed and if it conflicts if (data.Name && data.Name !== project.Name) { const existingProject = await this.GetProjectByName(data.Name); diff --git a/src/Utils/AutoRecovery.ts b/src/Utils/AutoRecovery.ts index d6b3a75..eaa7621 100644 --- a/src/Utils/AutoRecovery.ts +++ b/src/Utils/AutoRecovery.ts @@ -14,6 +14,7 @@ import fs from 'fs-extra'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; +import { AssertSafePath, RunFile } from './SafeExec'; import Logger from './Logger'; import SshKeyManager from './SshKeyManager'; @@ -170,34 +171,35 @@ export class AutoRecovery { try { // Option 1: Try to fix ownership to current user if (currentUid !== null && currentGid !== null) { - const chownCommand = `chown -R ${currentUid}:${currentGid} "${npmCacheDir}"`; + // ENG-C2 — argument arrays, no shell. + const chownArgs = ['-R', `${currentUid}:${currentGid}`, '--', AssertSafePath(npmCacheDir)]; Logger.Debug('Attempting to fix npm cache ownership', { - command: chownCommand, + command: `chown ${chownArgs.join(' ')}`, uid: currentUid, gid: currentGid }); try { - await execAsync(chownCommand, { timeout: 30000 }); + await RunFile('chown', chownArgs, { timeout: 30000 }); Logger.Info('npm cache ownership fixed successfully'); } catch (chownError) { // If chown fails (no sudo), try with sudo Logger.Warn('chown failed, trying with sudo', { error: chownError }); - await execAsync(`sudo ${chownCommand}`, { timeout: 30000 }); + await RunFile('sudo', ['-n', 'chown', ...chownArgs], { timeout: 30000 }); Logger.Info('npm cache ownership fixed with sudo'); } } // Option 2: Fix permissions (make cache writable) - const chmodCommand = `chmod -R 755 "${npmCacheDir}"`; - Logger.Debug('Fixing npm cache permissions', { command: chmodCommand }); + const chmodArgs = ['-R', '755', '--', AssertSafePath(npmCacheDir)]; + Logger.Debug('Fixing npm cache permissions', { command: `chmod ${chmodArgs.join(' ')}` }); try { - await execAsync(chmodCommand, { timeout: 30000 }); + await RunFile('chmod', chmodArgs, { timeout: 30000 }); Logger.Info('npm cache permissions fixed successfully'); } catch (chmodError) { Logger.Warn('chmod failed, trying with sudo', { error: chmodError }); - await execAsync(`sudo ${chmodCommand}`, { timeout: 30000 }); + await RunFile('sudo', ['-n', 'chmod', ...chmodArgs], { timeout: 30000 }); Logger.Info('npm cache permissions fixed with sudo'); } @@ -283,7 +285,7 @@ export class AutoRecovery { stdout = result.stdout; } else { // Linux/Unix - const result = await execAsync(`df -BG "${deploymentPath}"`); + const result = await RunFile('df', ['-BG', '--', AssertSafePath(deploymentPath)]); stdout = result.stdout; } @@ -444,7 +446,7 @@ export class AutoRecovery { // Try git fsck to check corruption try { - await execAsync('git fsck --full', { cwd: repoPath, timeout: 30000 }); + await RunFile('git', ['fsck', '--full'], { cwd: repoPath, timeout: 30000 }); return { Success: true, Action: 'git_repair', @@ -457,14 +459,14 @@ export class AutoRecovery { }); // Strategy 1: Clean and reset - await execAsync('git clean -fdx', { cwd: repoPath, timeout: 30000 }); - await execAsync('git reset --hard HEAD', { cwd: repoPath, timeout: 30000 }); + await RunFile('git', ['clean', '-fdx'], { cwd: repoPath, timeout: 30000 }); + await RunFile('git', ['reset', '--hard', 'HEAD'], { cwd: repoPath, timeout: 30000 }); // Strategy 2: Garbage collection - await execAsync('git gc --prune=now', { cwd: repoPath, timeout: 60000 }); + await RunFile('git', ['gc', '--prune=now'], { cwd: repoPath, timeout: 60000 }); // Verify repair - await execAsync('git fsck --full', { cwd: repoPath, timeout: 30000 }); + await RunFile('git', ['fsck', '--full'], { cwd: repoPath, timeout: 30000 }); Logger.Info('Git repository repaired successfully', { repoPath }); @@ -540,12 +542,11 @@ export class AutoRecovery { ): Promise { try { // Find processes using this path - const command = + const safePath = AssertSafePath(deploymentPath); + const { stdout } = process.platform === 'win32' - ? `handle.exe "${deploymentPath}" /accepteula` - : `lsof +D "${deploymentPath}"`; - - const { stdout } = await execAsync(command, { timeout: 10000 }); + ? await RunFile('handle.exe', [safePath, '/accepteula'], { timeout: 10000 }) + : await RunFile('lsof', ['+D', safePath], { timeout: 10000 }); if (!stdout || stdout.trim().length === 0) { return { diff --git a/src/Utils/SafeExec.ts b/src/Utils/SafeExec.ts new file mode 100644 index 0000000..0bbfec3 --- /dev/null +++ b/src/Utils/SafeExec.ts @@ -0,0 +1,332 @@ +/** + * SafeExec — shell-free process execution and input validation for git, + * rsync, chown and ssh-keygen (v3.0.1, ENG-C2). + * + * Every call goes through execFile with an argument array: no shell parses + * the values, so quotes, `;`, `$()`, backticks and newlines are just bytes. + * Values that git or rsync could still interpret as options (a leading `-`) + * are rejected by the validators below and separated with `--` at call sites. + * + * SecurityMiddleware's regex filter stays in front of the API as defence in + * depth, but nothing here relies on it. + */ + +import { execFile } from 'child_process'; + +export interface IRunOptions { + cwd?: string; + timeout?: number; + env?: NodeJS.ProcessEnv; + maxBuffer?: number; +} + +export interface IRunResult { + stdout: string; + stderr: string; +} + +/** A user- or webhook-supplied value failed validation. Maps to HTTP 400. */ +export class CommandValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'CommandValidationError'; + } +} + +/** + * Run `file` with `args` and no shell. Rejects with the usual execFile error + * (which carries stdout / stderr / code) on a non-zero exit or timeout. + */ +export function RunFile(file: string, args: string[], options: IRunOptions = {}): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + cwd: options.cwd, + timeout: options.timeout ?? 300000, + env: options.env ?? process.env, + maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + shell: false, + windowsHide: true, + encoding: 'utf8', + }, + (error, stdout, stderr) => { + if (error) { + Object.assign(error, { stdout, stderr }); + reject(error); + return; + } + resolve({ stdout, stderr }); + } + ); + }); +} + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; + +const BRANCH_CHARSET = /^[A-Za-z0-9._/+@-]+$/; + +/** Full or abbreviated lowercase hex object id. */ +export const COMMIT_HASH_PATTERN = /^[0-9a-f]{7,40}$/; + +export function IsValidCommitHash(value: unknown): value is string { + return typeof value === 'string' && COMMIT_HASH_PATTERN.test(value); +} + +export function AssertCommitHash(value: unknown): string { + if (!IsValidCommitHash(value)) { + throw new CommandValidationError( + 'Invalid commit hash: expected 7-40 lowercase hexadecimal characters' + ); + } + return value; +} + +/** + * Cheap structural checks done before asking git, so obviously hostile values + * never reach a process at all. + */ +function PrecheckBranch(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 255) { + throw new CommandValidationError('Invalid branch name: must be 1-255 characters'); + } + if (value.startsWith('-')) { + throw new CommandValidationError('Invalid branch name: must not start with "-"'); + } + if (CONTROL_CHARS.test(value) || /\s/.test(value)) { + throw new CommandValidationError('Invalid branch name: whitespace and control characters are not allowed'); + } + // Stricter than git: branch names are also substituted into pipeline + // commands ({{Branch}}), which run in a shell, so shell metacharacters are + // refused even though git itself would accept some of them. + if (!BRANCH_CHARSET.test(value)) { + throw new CommandValidationError( + 'Invalid branch name: only letters, digits and . _ / + @ - are allowed' + ); + } + return value; +} + +/** Synchronous subset of the branch rules, for places that cannot await. */ +export function IsPlausibleBranchName(value: unknown): value is string { + try { + PrecheckBranch(value); + return !(value as string).includes('..'); + } catch { + return false; + } +} + +/** + * Validate a branch name with `git check-ref-format --branch` (the + * authoritative rules) after the structural pre-check. Returns the name. + */ +export async function AssertBranchName(value: unknown): Promise { + const branch = PrecheckBranch(value); + try { + const { stdout } = await RunFile('git', ['check-ref-format', '--branch', branch], { + timeout: 5000, + }); + if (stdout.trim() !== branch) { + throw new CommandValidationError('Invalid branch name'); + } + } catch (error) { + if (error instanceof CommandValidationError) throw error; + throw new CommandValidationError('Invalid branch name'); + } + return branch; +} + +const SCP_STYLE = /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:(?!\/\/)[A-Za-z0-9._~/-]+$/; +const ALLOWED_URL_PROTOCOLS = new Set(['https:', 'ssh:']); + +/** + * Accept only https://, ssh:// and scp-style `user@host:path` repository + * URLs. Everything else (file://, ext::, local paths, option-like values) + * is rejected. + */ +export function AssertRepoUrl(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 2048) { + throw new CommandValidationError('Invalid repository URL'); + } + if (value.startsWith('-') || CONTROL_CHARS.test(value) || /\s/.test(value)) { + throw new CommandValidationError('Invalid repository URL: unexpected characters'); + } + + if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value)) { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new CommandValidationError('Invalid repository URL'); + } + if (!ALLOWED_URL_PROTOCOLS.has(parsed.protocol)) { + throw new CommandValidationError( + 'Invalid repository URL: only https://, ssh:// and git@host:path are allowed' + ); + } + if (!parsed.hostname || parsed.hostname.startsWith('-')) { + throw new CommandValidationError('Invalid repository URL: missing host'); + } + if (!parsed.pathname || parsed.pathname === '/') { + throw new CommandValidationError('Invalid repository URL: missing repository path'); + } + return value; + } + + if (SCP_STYLE.test(value)) { + const repoPath = value.slice(value.indexOf(':') + 1); + if (repoPath.startsWith('-')) { + throw new CommandValidationError('Invalid repository URL'); + } + return value; + } + + throw new CommandValidationError( + 'Invalid repository URL: only https://, ssh:// and git@host:path are allowed' + ); +} + +/** Long rsync options a project may set (anything that runs a command is absent). */ +const RSYNC_LONG_OPTIONS = new Set([ + '--recursive', + '--verbose', + '--quiet', + '--archive', + '--links', + '--copy-links', + '--safe-links', + '--hard-links', + '--perms', + '--no-perms', + '--times', + '--no-times', + '--omit-dir-times', + '--omit-link-times', + '--owner', + '--no-owner', + '--group', + '--no-group', + '--devices', + '--specials', + '--checksum', + '--compress', + '--delete', + '--delete-before', + '--delete-during', + '--delete-after', + '--delete-excluded', + '--ignore-errors', + '--ignore-existing', + '--update', + '--existing', + '--inplace', + '--whole-file', + '--no-whole-file', + '--human-readable', + '--itemize-changes', + '--stats', + '--progress', + '--prune-empty-dirs', + '--size-only', + '--no-o', + '--no-g', + '--no-p', + '--no-t', +]); + +/** Short flags allowed alone or bundled (e.g. `-rv`, `-avz`). */ +const RSYNC_SHORT_FLAGS = new Set('rvqalLHptogDczuOhimy'.split('')); + +/** Options that take a value, with the accepted value shape. */ +const RSYNC_VALUE_OPTIONS: Record = { + '--chmod': /^[ugoaDF0-7,=+\-rwxXst]+$/, + '--chown': /^[A-Za-z0-9._-]*(:[A-Za-z0-9._-]*)?$/, + '--timeout': /^[0-9]{1,5}$/, + '--bwlimit': /^[0-9]{1,9}[KMG]?$/, + '--max-size': /^[0-9]{1,12}[KMGkmg]?$/, + '--min-size': /^[0-9]{1,12}[KMGkmg]?$/, + '--modify-window': /^[0-9]{1,5}$/, +}; + +/** + * Split a project's RsyncOptions string into arguments, accepting only + * allow-listed options. Throws CommandValidationError on anything else + * (notably -e / --rsh / --rsync-path, which execute commands). + */ +export function ParseRsyncOptions(value: unknown): string[] { + if (value === undefined || value === null || value === '') return []; + if (typeof value !== 'string' || value.length > 1000 || CONTROL_CHARS.test(value)) { + throw new CommandValidationError('Invalid rsync options'); + } + + const args = value.trim().split(/\s+/).filter(Boolean); + for (const arg of args) { + if (/^-[A-Za-z]+$/.test(arg)) { + const flags = arg.slice(1).split(''); + if (!flags.every((flag) => RSYNC_SHORT_FLAGS.has(flag))) { + throw new CommandValidationError(`rsync option not allowed: ${arg}`); + } + continue; + } + + if (arg.startsWith('--') && arg.includes('=')) { + const name = arg.slice(0, arg.indexOf('=')); + const optionValue = arg.slice(arg.indexOf('=') + 1); + const shape = RSYNC_VALUE_OPTIONS[name]; + if (!shape || !shape.test(optionValue)) { + throw new CommandValidationError(`rsync option not allowed: ${name}`); + } + continue; + } + + if (!RSYNC_LONG_OPTIONS.has(arg)) { + throw new CommandValidationError(`rsync option not allowed: ${arg}`); + } + } + return args; +} + +/** Reject a filesystem path that could be read as an option or smuggle bytes. */ +export function AssertSafePath(value: unknown, label: string = 'path'): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 4096 || + value.startsWith('-') || + CONTROL_CHARS.test(value) + ) { + throw new CommandValidationError(`Invalid ${label}`); + } + return value; +} + +/** + * Strip credentials from a URL for logs ("https://token@host/x" → "https://host/x"). + */ +export function RedactUrlCredentials(value: string): string { + try { + const parsed = new URL(value); + if (parsed.username || parsed.password) { + parsed.username = ''; + parsed.password = ''; + return parsed.toString(); + } + } catch { + // scp-style or invalid: no embedded secret to strip + } + return value; +} + +export default { + RunFile, + AssertCommitHash, + AssertBranchName, + AssertRepoUrl, + ParseRsyncOptions, + AssertSafePath, + IsValidCommitHash, + RedactUrlCredentials, +}; diff --git a/src/Utils/SshKeyGenerator.ts b/src/Utils/SshKeyGenerator.ts index 85d31c7..acc12cd 100644 --- a/src/Utils/SshKeyGenerator.ts +++ b/src/Utils/SshKeyGenerator.ts @@ -10,14 +10,12 @@ */ import crypto from 'crypto'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import fs from 'fs-extra'; import path from 'path'; +import { RunFile } from './SafeExec'; import Logger from './Logger'; -const execAsync = promisify(exec); export interface ISshKeyPair { publicKey: string; @@ -26,6 +24,15 @@ export interface ISshKeyPair { keyType: 'ed25519' | 'rsa'; } const TempPath = path.join(process.cwd(), 'tmp', 'temp-ssh-keys'); +/** + * Key comments end up in authorized_keys / deploy-key UIs; keep them to a + * conservative character set and length. + */ +export function SanitizeKeyComment(comment: string): string { + const cleaned = comment.replace(/[^A-Za-z0-9._@+-]/g, '-').slice(0, 100); + return cleaned.length > 0 ? cleaned : 'deploy-center'; +} + export class SshKeyGenerator { public static GetTmpDir() { if (!fs.existsSync(TempPath)) { @@ -62,11 +69,13 @@ export class SshKeyGenerator { // -N "": No passphrase (keys are encrypted in database) // -C: Comment // -m PEM: Use PEM format (compatible with older OpenSSL/Git) - const command = `ssh-keygen -t ed25519 -m PEM -f "${keyPath}" -N "" -C "${comment}"`; - console.log({ - command, - }); - await execAsync(command, { timeout: 10000 }); + // ENG-C2 — argument array, no shell: the comment (derived from the + // project name) cannot break out of its argument. + await RunFile( + 'ssh-keygen', + ['-t', 'ed25519', '-m', 'PEM', '-f', keyPath, '-N', '', '-C', SanitizeKeyComment(comment)], + { timeout: 10000 } + ); // Read generated keys const privateKey = await fs.readFile(keyPath, 'utf-8'); @@ -134,9 +143,24 @@ export class SshKeyGenerator { // Generate RSA key // -m PEM: Use PEM format (compatible with older OpenSSL/Git) - const command = `ssh-keygen -t rsa -b ${bits} -m PEM -f "${keyPath}" -N "" -C "${comment}"`; - - await execAsync(command, { timeout: 30000 }); // RSA takes longer + await RunFile( + 'ssh-keygen', + [ + '-t', + 'rsa', + '-b', + String(Math.trunc(bits)), + '-m', + 'PEM', + '-f', + keyPath, + '-N', + '', + '-C', + SanitizeKeyComment(comment), + ], + { timeout: 30000 } // RSA takes longer + ); const privateKey = await fs.readFile(keyPath, 'utf-8'); const publicKey = await fs.readFile(`${keyPath}.pub`, 'utf-8'); @@ -251,7 +275,7 @@ export class SshKeyGenerator { */ public static async CheckSshKeygenAvailable(): Promise { try { - await execAsync('ssh-keygen -V', { timeout: 5000 }); + await RunFile('ssh-keygen', ['-V'], { timeout: 5000 }); return true; } catch (error) { Logger.Error('ssh-keygen not found', error as Error); diff --git a/src/Utils/SshKeyManager.ts b/src/Utils/SshKeyManager.ts index 35db3f5..f88018f 100644 --- a/src/Utils/SshKeyManager.ts +++ b/src/Utils/SshKeyManager.ts @@ -27,13 +27,10 @@ import fs from 'fs-extra'; import path from 'path'; import crypto from 'crypto'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import Logger from './Logger'; +import { RunFile } from './SafeExec'; import EncryptionHelper, { type IEncryptedData } from './EncryptionHelper'; -const execAsync = promisify(exec); - export interface ISshKeyContext { keyPath: string; cleanup: () => Promise; @@ -241,14 +238,15 @@ export class SshKeyManager { * - UserKnownHostsFile=/dev/null: Don't use known_hosts * - LogLevel=ERROR: Minimize output for security * - * @param command - Git command to execute (e.g., "git clone ...") + * @param args - git arguments (e.g. ['clone', '--', url, '.']); executed + * without a shell (ENG-C2) * @param keyPath - Path to temporary SSH key file * @param workingDir - Working directory for command execution * @param options - Additional options * @returns Promise<{stdout, stderr}> - Command output */ public static async ExecuteGitCommandWithKey( - command: string, + args: string[], keyPath: string, workingDir: string, options: { @@ -261,10 +259,12 @@ export class SshKeyManager { timeout = 300000, // 5 minutes default } = options; - // Build GIT_SSH_COMMAND with security options + // Build GIT_SSH_COMMAND with security options. git hands this string to + // a shell, so the (server-generated) key path is single-quoted. + const quotedKeyPath = `'${keyPath.replace(/'/g, `'\\''`)}'`; const sshCommand = [ 'ssh', - `-i "${keyPath}"`, // Use our temporary key + `-i ${quotedKeyPath}`, // Use our temporary key `-o StrictHostKeyChecking=${strictHostKeyChecking ? 'yes' : 'no'}`, `-o UserKnownHostsFile=/dev/null`, // Don't use known_hosts `-o IdentitiesOnly=yes`, // Only use our key, ignore ssh-agent @@ -273,13 +273,13 @@ export class SshKeyManager { ].join(' '); Logger.Debug('Executing git command with SSH key', { - command: command.substring(0, 100) + '...', + command: `git ${args[0] ?? ''}`, workingDir, strictHostKeyChecking, }); try { - const { stdout, stderr } = await execAsync(command, { + const { stdout, stderr } = await RunFile('git', args, { cwd: workingDir, timeout, env: { @@ -297,7 +297,7 @@ export class SshKeyManager { return { stdout, stderr }; } catch (error) { Logger.Error('Git command execution failed', error as Error, { - command: command.substring(0, 100), + command: `git ${args[0] ?? ''}`, workingDir, }); throw error; From 0d56f9d175aad9fbb01ed67bf9dbe81739b0d976 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:20:28 +0000 Subject: [PATCH 07/22] fix(rbac): enforce project access everywhere and make secrets write-only (SEC-H4, SEC-H3) Access (SEC-H4): - ProjectAccess and DeploymentAccess middleware now delegate to AccessControlService (membership, or legacy CreatedBy), the same rules as the Socket.IO rooms. Viewers only see projects they are members of, as documented in the RBAC matrix; Developer deployment access is based on membership instead of CreatedBy alone. - Added checks to: deployment log download, manual deploy, project deployment listing, project queue status, per-project statistics, GET /projects/name/:name, the public SSH key, and SSH key generate/regenerate/delete/toggle (previously role-only). - The test-webhook endpoints require authentication and project access. - Managers are no longer refused on SSH key management, project deploy and cancel, matching the RBAC matrix. Secrets (SEC-H3): - Project responses go through ToProjectDto: WebhookSecret and the encrypted SSH key are removed, RepoUrl credentials are stripped, and Config secrets (legacy variables except the platform build keys, notification webhook URLs, SMTP passwords, bot tokens) are replaced with a mask. A Secrets map reports { HasValue } per write-only field. - Deployment responses embed the same project DTO. - Saving a project with the mask (or the credential-free RepoUrl) keeps the stored value, so clients never need the secret to edit a project. - Env var listings add HasValue; user management responses drop PasswordHash. - Route-table tests for Developer and Viewer outsiders, member and Manager cases, and payload scans for leaked secrets. --- __tests__/integration/AccessControl.test.ts | 236 +++++++++++++ src/Controllers/DeploymentController.ts | 60 ++-- src/Controllers/ProjectController.ts | 53 ++- src/Controllers/UsersController.ts | 13 +- src/Controllers/WebhookController.ts | 5 +- src/Middleware/DeploymentAccessMiddleware.ts | 241 ++++--------- src/Middleware/ProjectAccessMiddleware.ts | 353 +++++-------------- src/Routes/DeploymentRoutes.ts | 7 + src/Routes/ProjectRoutes.ts | 9 +- src/Routes/WebhookRoutes.ts | 5 + src/Services/EnvironmentVariableService.ts | 3 + src/Services/ProjectService.ts | 18 + src/Utils/DtoMapper.ts | 206 ++++++++++- 13 files changed, 723 insertions(+), 486 deletions(-) create mode 100644 __tests__/integration/AccessControl.test.ts diff --git a/__tests__/integration/AccessControl.test.ts b/__tests__/integration/AccessControl.test.ts new file mode 100644 index 0000000..7875dcd --- /dev/null +++ b/__tests__/integration/AccessControl.test.ts @@ -0,0 +1,236 @@ +/** + * Project / deployment authorization + secret DTOs — v3.0.1 / SEC-H4, SEC-H3. + * + * A route table is exercised for every role against a project the caller is + * NOT a member of, then against one they are, and project payloads are + * checked for leaked secrets. + */ + +import request from 'supertest'; +import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; +import { makeDeployment, makeProject, makeUser } from '../helpers/factories'; +import { authHeader } from '../helpers/token'; +import { buildTestApp } from '../helpers/testApp'; +import DeploymentRoutes from '@Routes/DeploymentRoutes'; +import ProjectRoutes from '@Routes/ProjectRoutes'; +import WebhookRoutes from '@Routes/WebhookRoutes'; +import UsersRoutes from '@Routes/UsersRoutes'; +import QueueService from '@Services/QueueService'; +import { Deployment, Project, ProjectMember } from '@Models/index'; +import type { User } from '@Models/User'; +import { SECRET_MASK } from '@Utils/DtoMapper'; + +async function dbReachable(): Promise { + try { + await setupTestDb(); + return true; + } catch { + return false; + } +} + +type TMethod = 'get' | 'post' | 'put' | 'delete' | 'patch'; + +const SECRET_VAR = 'sk_live_super_secret_value_123'; +const TELEGRAM_TOKEN = '123456:telegram-bot-token-secret'; + +describe('Access control + secret DTOs — SEC-H4 / SEC-H3', () => { + let dbUp = false; + let app: import('express').Application; + + beforeAll(async () => { + dbUp = await dbReachable(); + }); + + afterAll(async () => { + if (dbUp) await teardownTestDb(); + }); + + beforeEach(async () => { + if (!dbUp) return; + await truncateAll(); + app = buildTestApp([ + { path: '/api/deployments', router: new DeploymentRoutes().Router }, + { path: '/api/projects', router: new ProjectRoutes().Router }, + { path: '/api/webhooks', router: new WebhookRoutes().Router }, + { path: '/api/users', router: new UsersRoutes().Router }, + ]); + jest.spyOn(QueueService.GetInstance(), 'IsReady').mockReturnValue(true); + jest + .spyOn(QueueService.GetInstance(), 'Enqueue') + .mockImplementation(async (id: number) => `dep-${id}`); + }); + + async function fixture(): Promise<{ admin: User; project: Project; deployment: Deployment }> { + const admin = await makeUser({ Role: 'Admin' }); + const project = await makeProject({ + CreatedBy: admin.Id, + Config: { + Branch: 'main', + AutoDeploy: false, + Pipeline: [], + Variables: { API_KEY: SECRET_VAR, BuildCommand: 'npm run build' }, + Notifications: { + Channels: [], + OnSuccess: true, + OnFailure: true, + OnStart: false, + Telegram: { Enabled: true, BotToken: TELEGRAM_TOKEN, ChatId: '1' }, + }, + }, + }); + const deployment = await makeDeployment({ ProjectId: project.Id }); + return { admin, project, deployment }; + } + + const routes = (p: Project, d: Deployment): Array<[TMethod, string, Record?]> => [ + ['get', `/api/projects/${p.Id}`], + ['get', `/api/projects/${p.Id}/deployments`], + ['get', `/api/projects/${p.Id}/statistics`], + ['get', `/api/projects/${p.Id}/ssh-key`], + ['get', `/api/deployments/${d.Id}`], + ['get', `/api/deployments/${d.Id}/logs`], + ['get', `/api/deployments/${d.Id}/log/download`], + ['get', `/api/deployments/projects/${p.Id}/deployments`], + ['get', `/api/deployments/projects/${p.Id}/queue/status`], + ['get', `/api/deployments/statistics?projectId=${p.Id}`], + ['post', `/api/deployments/projects/${p.Id}/deploy`, { Branch: 'main' }], + ['post', `/api/projects/${p.Id}/deploy`, {}], + ['post', `/api/projects/${p.Id}/ssh-key`, {}], + ['put', `/api/projects/${p.Id}/ssh-key`, {}], + ['delete', `/api/projects/${p.Id}/ssh-key`], + ['patch', `/api/projects/${p.Id}/ssh-key/toggle`, { Enabled: false }], + ['post', `/api/projects/${p.Id}/regenerate-webhook`, {}], + ['post', `/api/deployments/${d.Id}/cancel`, {}], + ]; + + it.each(['Developer', 'Viewer'] as const)( + '%s who is not a member is refused on every project / deployment route', + async (role) => { + if (!dbUp) return; + const { project, deployment } = await fixture(); + const outsider = await makeUser({ Role: role }); + + const failures: string[] = []; + for (const [method, url, body] of routes(project, deployment)) { + const res = await request(app)[method](url).set(authHeader(outsider)).send(body ?? {}); + if (res.status !== 403 && res.status !== 404) failures.push(`${method.toUpperCase()} ${url} → ${res.status}`); + } + expect(failures).toEqual([]); + expect(await Deployment.count()).toBe(1); + } + ); + + it('GET /projects/name/:name hides projects the caller cannot see', async () => { + if (!dbUp) return; + const { project } = await fixture(); + const outsider = await makeUser({ Role: 'Viewer' }); + const res = await request(app).get(`/api/projects/name/${project.Name}`).set(authHeader(outsider)); + expect(res.status).toBe(404); + }); + + it('test-webhook endpoints require authentication and project access', async () => { + if (!dbUp) return; + const { project, admin } = await fixture(); + const anon = await request(app).get(`/api/webhooks/test/${project.Name}`); + expect(anon.status).toBe(401); + const outsider = await makeUser({ Role: 'Developer' }); + const denied = await request(app).get(`/api/webhooks/test/${project.Name}`).set(authHeader(outsider)); + expect(denied.status).toBe(404); + const ok = await request(app).get(`/api/webhooks/test/${project.Name}`).set(authHeader(admin)); + expect(ok.status).toBe(200); + }); + + it('a Viewer member can read but not mutate; a Developer member can deploy', async () => { + if (!dbUp) return; + const { project, deployment, admin } = await fixture(); + const viewer = await makeUser({ Role: 'Viewer' }); + const dev = await makeUser({ Role: 'Developer' }); + await ProjectMember.create({ ProjectId: project.Id, UserId: viewer.Id, Role: 'member', AddedBy: admin.Id }); + await ProjectMember.create({ ProjectId: project.Id, UserId: dev.Id, Role: 'member', AddedBy: admin.Id }); + + expect((await request(app).get(`/api/projects/${project.Id}`).set(authHeader(viewer))).status).toBe(200); + expect((await request(app).get(`/api/deployments/${deployment.Id}`).set(authHeader(viewer))).status).toBe(200); + expect( + (await request(app).post(`/api/deployments/projects/${project.Id}/deploy`).set(authHeader(viewer)).send({ Branch: 'main' })).status + ).toBe(403); + expect( + (await request(app).post(`/api/deployments/projects/${project.Id}/deploy`).set(authHeader(dev)).send({ Branch: 'main' })).status + ).toBe(201); + }); + + it('a Manager is no longer locked out of SSH key management or cancel', async () => { + if (!dbUp) return; + const { project, deployment } = await fixture(); + const manager = await makeUser({ Role: 'Manager' }); + const ssh = await request(app).delete(`/api/projects/${project.Id}/ssh-key`).set(authHeader(manager)); + expect(ssh.status).not.toBe(403); + const cancel = await request(app).post(`/api/deployments/${deployment.Id}/cancel`).set(authHeader(manager)).send({}); + expect(cancel.status).not.toBe(403); + }); + + it('project payloads never contain secrets but report HasValue', async () => { + if (!dbUp) return; + const { project, deployment, admin } = await fixture(); + const secret = project.get('WebhookSecret') as string; + + const responses = [ + await request(app).get(`/api/projects/${project.Id}`).set(authHeader(admin)), + await request(app).get('/api/projects').set(authHeader(admin)), + await request(app).get(`/api/projects/name/${project.Name}`).set(authHeader(admin)), + await request(app).get(`/api/deployments/${deployment.Id}`).set(authHeader(admin)), + ]; + for (const res of responses) { + expect(res.status).toBe(200); + const body = JSON.stringify(res.body); + expect(body).not.toContain(secret); + expect(body).not.toContain(SECRET_VAR); + expect(body).not.toContain(TELEGRAM_TOKEN); + expect(body).not.toContain('SshKeyEncrypted'); + expect(body).not.toContain('"WebhookSecret":"'); + } + + const dto = responses[0]!.body.Data.Project; + expect(dto.Secrets.WebhookSecret).toEqual({ HasValue: true }); + expect(dto.Secrets['Config.Variables.API_KEY']).toEqual({ HasValue: true }); + expect(dto.Config.Variables.API_KEY).toBe(SECRET_MASK); + expect(dto.Config.Variables.BuildCommand).toBe('npm run build'); + }); + + it('saving a project with masked values keeps the stored secrets; new values replace them', async () => { + if (!dbUp) return; + const { project, admin } = await fixture(); + const read = await request(app).get(`/api/projects/${project.Id}`).set(authHeader(admin)); + const config = read.body.Data.Project.Config; + + const untouched = await request(app).put(`/api/projects/${project.Id}`).set(authHeader(admin)).send({ Config: config }); + expect(untouched.status).toBe(200); + await project.reload(); + expect(project.Config.Variables.API_KEY).toBe(SECRET_VAR); + expect(project.Config.Notifications?.Telegram?.BotToken).toBe(TELEGRAM_TOKEN); + + config.Variables.API_KEY = 'rotated_value'; + await request(app).put(`/api/projects/${project.Id}`).set(authHeader(admin)).send({ Config: config }); + await project.reload(); + expect(project.Config.Variables.API_KEY).toBe('rotated_value'); + }); + + it('the regenerated webhook secret is returned once, to an authorized caller', async () => { + if (!dbUp) return; + const { project, admin } = await fixture(); + const res = await request(app).post(`/api/projects/${project.Id}/regenerate-webhook`).set(authHeader(admin)).send({}); + expect(res.status).toBe(200); + expect(typeof res.body.Data.WebhookSecret).toBe('string'); + const after = await request(app).get(`/api/projects/${project.Id}`).set(authHeader(admin)); + expect(JSON.stringify(after.body)).not.toContain(res.body.Data.WebhookSecret); + }); + + it('user management responses do not include password hashes', async () => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const target = await makeUser({ Role: 'Viewer' }); + const res = await request(app).get(`/api/users/${target.Id}`).set(authHeader(admin)); + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain('PasswordHash'); + }); +}); diff --git a/src/Controllers/DeploymentController.ts b/src/Controllers/DeploymentController.ts index b7779b9..b5281d7 100644 --- a/src/Controllers/DeploymentController.ts +++ b/src/Controllers/DeploymentController.ts @@ -8,6 +8,8 @@ import { Request, Response } from 'express'; import DeploymentService from '@Services/DeploymentService'; import QueueService from '@Services/QueueService'; import RollbackService, { RollbackError } from '@Services/RollbackService'; +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; +import { ToDeploymentResponseDto } from '@Utils/DtoMapper'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import { EUserRole } from '@Types/ICommon'; @@ -46,7 +48,7 @@ export class DeploymentController { ); ResponseHelper.Success(res, 'Deployments retrieved successfully', { - Deployments: result.Deployments, + Deployments: result.Deployments.map((d) => ToDeploymentResponseDto(d)), Total: result.Total, Limit: limit, Offset: offset, @@ -77,7 +79,7 @@ export class DeploymentController { return; } - ResponseHelper.Success(res, 'Deployment retrieved successfully', { Deployment: deployment }); + ResponseHelper.Success(res, 'Deployment retrieved successfully', { Deployment: ToDeploymentResponseDto(deployment) }); } catch (error) { Logger.Error('Failed to get deployment', error as Error); ResponseHelper.Error(res, 'Failed to retrieve deployment'); @@ -171,7 +173,7 @@ export class DeploymentController { const result = await this.DeploymentService.GetProjectDeployments(projectId, limit, offset); ResponseHelper.Success(res, 'Deployments retrieved successfully', { - Deployments: result.Deployments, + Deployments: result.Deployments.map((d) => ToDeploymentResponseDto(d)), Total: result.Total, Limit: limit, Offset: offset, @@ -220,7 +222,7 @@ export class DeploymentController { UserId: userId, }); - ResponseHelper.Created(res, 'Deployment created successfully', { Deployment: deployment }); + ResponseHelper.Created(res, 'Deployment created successfully', { Deployment: ToDeploymentResponseDto(deployment) }); } catch (error) { Logger.Error('Failed to create manual deployment', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -237,8 +239,13 @@ export class DeploymentController { const userId = (req as any).user?.UserId; const userRole = (req as any).user?.Role; - // Only admins and developers can cancel deployments - if (userRole !== EUserRole.Admin && userRole !== EUserRole.Developer) { + // Admins, Managers and Developers can cancel; project membership is + // enforced by DeploymentAccessMiddleware.CheckDeploymentModifyAccess. + if ( + userRole !== EUserRole.Admin && + userRole !== EUserRole.Manager && + userRole !== EUserRole.Developer + ) { ResponseHelper.Forbidden(res, 'Insufficient permissions to cancel deployment'); return; } @@ -281,7 +288,7 @@ export class DeploymentController { const newDeployment = await this.DeploymentService.RetryDeployment(deploymentId, userId); ResponseHelper.Created(res, 'Deployment retry initiated successfully', { - Deployment: newDeployment, + Deployment: ToDeploymentResponseDto(newDeployment), }); } catch (error) { Logger.Error('Failed to retry deployment', error as Error); @@ -351,6 +358,16 @@ export class DeploymentController { return; } + // SEC-H4: per-project statistics follow project visibility. + const subject = (req as Request & { user?: IAccessSubject }).user; + if ( + projectId !== undefined && + (!subject || !(await AccessControlService.CanViewProject(subject, projectId))) + ) { + ResponseHelper.Forbidden(res, 'You do not have access to this project'); + return; + } + const statistics = await this.DeploymentService.GetDeploymentStatistics(projectId); ResponseHelper.Success(res, 'Statistics retrieved successfully', { Statistics: statistics }); @@ -380,28 +397,15 @@ export class DeploymentController { let filteredQueues = allQueues; - // Developer and Viewer: filter by project membership + // Developer and Viewer: same visibility rule as every other route. if (!canSeeAllQueues && userId) { - const { Project, ProjectMember } = await import('@Models/index'); - - // Get all projects where user is a member - const userProjects = await Project.findAll({ - attributes: ['Id'], - include: [ - { - model: ProjectMember, - as: 'Members', - where: { UserId: userId }, - required: true, - attributes: [], - }, - ], - }); - - const allowedProjectIds = userProjects.map((p) => p.Id); - - // Filter queues to only include allowed projects - filteredQueues = allQueues.filter((q) => allowedProjectIds.includes(q.ProjectId)); + const accessible = await AccessControlService.GetAccessibleProjectIds( + user as IAccessSubject + ); + const allowedProjectIds = accessible === 'all' ? null : new Set(accessible); + filteredQueues = allowedProjectIds + ? allQueues.filter((q) => allowedProjectIds.has(q.ProjectId)) + : allQueues; } ResponseHelper.Success(res, 'Queue status retrieved successfully', { diff --git a/src/Controllers/ProjectController.ts b/src/Controllers/ProjectController.ts index 02b0669..15e7953 100644 --- a/src/Controllers/ProjectController.ts +++ b/src/Controllers/ProjectController.ts @@ -10,6 +10,8 @@ import DeploymentService from '@Services/DeploymentService'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import { EUserRole } from '@Types/ICommon'; +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; +import { ToDeploymentResponseDto, ToProjectDto } from '@Utils/DtoMapper'; export class ProjectController { private readonly ProjectService: ProjectService; @@ -40,7 +42,7 @@ export class ProjectController { userRole ); - ResponseHelper.Success(res, 'Projects retrieved successfully', { Projects: projects }); + ResponseHelper.Success(res, 'Projects retrieved successfully', { Projects: projects.map((p) => ToProjectDto(p)) }); } catch (error) { Logger.Error('Failed to get projects', error as Error); ResponseHelper.Error(res, 'Failed to retrieve projects'); @@ -67,7 +69,7 @@ export class ProjectController { return; } - ResponseHelper.Success(res, 'Project retrieved successfully', { Project: project }); + ResponseHelper.Success(res, 'Project retrieved successfully', { Project: ToProjectDto(project) }); } catch (error) { Logger.Error('Failed to get project', error as Error); ResponseHelper.Error(res, 'Failed to retrieve project'); @@ -113,7 +115,7 @@ export class ProjectController { req ); - ResponseHelper.Created(res, 'Project created successfully', { Project: project }); + ResponseHelper.Created(res, 'Project created successfully', { Project: ToProjectDto(project) }); } catch (error) { Logger.Error('Failed to create project', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -165,7 +167,7 @@ export class ProjectController { req ); - ResponseHelper.Success(res, 'Project updated successfully', { Project: project }); + ResponseHelper.Success(res, 'Project updated successfully', { Project: ToProjectDto(project) }); } catch (error) { Logger.Error('Failed to update project', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -263,12 +265,15 @@ export class ProjectController { const project = await this.ProjectService.GetProjectByName(projectName); - if (!project) { + // SEC-H4: same visibility rule as GET /projects/:id. Unknown and + // forbidden names both answer 404 so names cannot be probed. + const subject = (req as Request & { user?: IAccessSubject }).user; + if (!project || !subject || !(await AccessControlService.CanViewProject(subject, project.Id))) { ResponseHelper.NotFound(res, 'Project not found'); return; } - ResponseHelper.Success(res, 'Project retrieved successfully', { Project: project }); + ResponseHelper.Success(res, 'Project retrieved successfully', { Project: ToProjectDto(project) }); } catch (error) { Logger.Error('Failed to get project by name', error as Error); ResponseHelper.Error(res, 'Failed to retrieve project'); @@ -287,7 +292,13 @@ export class ProjectController { const userRole = (req as any).user?.Role; // Only admins and developers can trigger deployments - if (userRole !== EUserRole.Admin && userRole !== EUserRole.Developer) { + // Project membership is enforced by CheckProjectModifyAccess (SEC-H4); + // Manager is allowed per the RBAC matrix. + if ( + userRole !== EUserRole.Admin && + userRole !== EUserRole.Manager && + userRole !== EUserRole.Developer + ) { ResponseHelper.Forbidden(res, 'Insufficient permissions to trigger deployment'); return; } @@ -307,7 +318,7 @@ export class ProjectController { UserId: userId, }); - ResponseHelper.Created(res, 'Deployment created successfully', { Deployment: deployment }); + ResponseHelper.Created(res, 'Deployment created successfully', { Deployment: ToDeploymentResponseDto(deployment) }); } catch (error) { Logger.Error('Failed to create deployment', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -332,7 +343,7 @@ export class ProjectController { const result = await this.DeploymentService.GetProjectDeployments(projectId, limit, offset); ResponseHelper.Success(res, 'Deployments retrieved successfully', { - Deployments: result.Deployments, + Deployments: result.Deployments.map((d) => ToDeploymentResponseDto(d)), Total: result.Total, Limit: limit, Offset: offset, @@ -357,7 +368,13 @@ export class ProjectController { const userRole = (req as any).user?.Role; // Only admins and developers can generate SSH keys - if (userRole !== EUserRole.Admin && userRole !== EUserRole.Developer) { + // Project membership is enforced by CheckProjectModifyAccess (SEC-H4); + // Manager is allowed per the RBAC matrix. + if ( + userRole !== EUserRole.Admin && + userRole !== EUserRole.Manager && + userRole !== EUserRole.Developer + ) { ResponseHelper.Forbidden(res, 'Insufficient permissions to generate SSH keys'); return; } @@ -400,7 +417,13 @@ export class ProjectController { const { keyType } = req.body; // Only admins and developers can regenerate SSH keys - if (userRole !== EUserRole.Admin && userRole !== EUserRole.Developer) { + // Project membership is enforced by CheckProjectModifyAccess (SEC-H4); + // Manager is allowed per the RBAC matrix. + if ( + userRole !== EUserRole.Admin && + userRole !== EUserRole.Manager && + userRole !== EUserRole.Developer + ) { ResponseHelper.Forbidden(res, 'Insufficient permissions to regenerate SSH keys'); return; } @@ -441,7 +464,13 @@ export class ProjectController { const userRole = (req as any).user?.Role; // Only admins and developers can delete SSH keys - if (userRole !== EUserRole.Admin && userRole !== EUserRole.Developer) { + // Project membership is enforced by CheckProjectModifyAccess (SEC-H4); + // Manager is allowed per the RBAC matrix. + if ( + userRole !== EUserRole.Admin && + userRole !== EUserRole.Manager && + userRole !== EUserRole.Developer + ) { ResponseHelper.Forbidden(res, 'Insufficient permissions to delete SSH keys'); return; } diff --git a/src/Controllers/UsersController.ts b/src/Controllers/UsersController.ts index 8a1c8b9..c9f3558 100644 --- a/src/Controllers/UsersController.ts +++ b/src/Controllers/UsersController.ts @@ -4,6 +4,7 @@ */ import { Request, Response } from 'express'; +import { ToUserDto } from '@Utils/DtoMapper'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import UserSettingsService, { @@ -600,7 +601,7 @@ export class UsersController { const user = await this.UserProfileService.GetUserById(userId); - ResponseHelper.Success(res, 'User retrieved successfully', user); + ResponseHelper.Success(res, 'User retrieved successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to get user', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -633,7 +634,7 @@ export class UsersController { fullName, }); - ResponseHelper.Created(res, 'User created successfully', user); + ResponseHelper.Created(res, 'User created successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to create user', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -666,7 +667,7 @@ export class UsersController { fullName, }); - ResponseHelper.Success(res, 'User updated successfully', user); + ResponseHelper.Success(res, 'User updated successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to update user', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -699,7 +700,7 @@ export class UsersController { const user = await this.UserProfileService.ChangeUserRole(userId, role); - ResponseHelper.Success(res, 'User role changed successfully', user); + ResponseHelper.Success(res, 'User role changed successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to change user role', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -753,7 +754,7 @@ export class UsersController { const user = await this.UserProfileService.ActivateUser(userId); - ResponseHelper.Success(res, 'User activated successfully', user); + ResponseHelper.Success(res, 'User activated successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to activate user', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); @@ -780,7 +781,7 @@ export class UsersController { const user = await this.UserProfileService.DeactivateUser(userId); - ResponseHelper.Success(res, 'User deactivated successfully', user); + ResponseHelper.Success(res, 'User deactivated successfully', ToUserDto(user)); } catch (error) { Logger.Error('Failed to deactivate user', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); diff --git a/src/Controllers/WebhookController.ts b/src/Controllers/WebhookController.ts index 5254d5b..53d1425 100644 --- a/src/Controllers/WebhookController.ts +++ b/src/Controllers/WebhookController.ts @@ -8,6 +8,7 @@ import { Request, Response } from 'express'; import ProjectService from '@Services/ProjectService'; import WebhookService from '@Services/WebhookService'; import DeploymentService from '@Services/DeploymentService'; +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; @@ -218,7 +219,9 @@ export class WebhookController { // Get project by name const project = await this.ProjectService.GetProjectByName(projectName); - if (!project) { + // SEC-H4: authenticated, and only for projects the caller can view. + const subject = (req as Request & { user?: IAccessSubject }).user; + if (!project || !subject || !(await AccessControlService.CanViewProject(subject, project.Id))) { ResponseHelper.NotFound(res, 'Project not found'); return; } diff --git a/src/Middleware/DeploymentAccessMiddleware.ts b/src/Middleware/DeploymentAccessMiddleware.ts index 5d2b103..b7d3572 100644 --- a/src/Middleware/DeploymentAccessMiddleware.ts +++ b/src/Middleware/DeploymentAccessMiddleware.ts @@ -1,209 +1,102 @@ /** * Deployment Access Control Middleware - * Validates user access to specific deployments based on project ownership - * Following SOLID principles and PascalCase naming convention + * Validates user access to specific deployments through their project. + * + * v3.0.1 (SEC-H4): decisions come from AccessControlService, i.e. project + * membership (or legacy CreatedBy) — the same rules as ProjectAccess and the + * Socket.IO deployment rooms. * * Access Control: - * - Admin: Full access to all deployments - * - Manager: Full access (project-management role, same as Admin for deploys) - * - Developer: Access to deployments of own projects only - * - Viewer: Read-only access to all deployments + * - Admin / Manager: full access to all deployments + * - Developer: deployments of projects they are a member of (view + modify) + * - Viewer: read-only, deployments of projects they are a member of */ import { Request, Response, NextFunction } from 'express'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import { EUserRole } from '@Types/ICommon'; -import { Deployment, Project } from '@Models/index'; - -export class DeploymentAccessMiddleware { - /** - * Check if user has access to a specific deployment - * - Admin: Full access to all deployments - * - Developer: Access to deployments of own projects only - * - Viewer: Read-only access to all deployments - */ - public CheckDeploymentAccess = async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - try { - const user = (req as any).user; - const deploymentIdParam = req.params.id; - - if (!user) { - ResponseHelper.Unauthorized(res, 'Authentication required'); - return; - } - - if (!deploymentIdParam) { - ResponseHelper.ValidationError(res, 'Deployment ID is required'); - return; - } - - const deploymentId = parseInt(deploymentIdParam, 10); - - if (isNaN(deploymentId)) { - ResponseHelper.ValidationError(res, 'Invalid deployment ID'); - return; - } - - // Admin + Manager have full access (Manager is the project-management - // role; treating them identically here matches the rest of the codebase - // and the F-007 rollback contract docs). - if (user.Role === EUserRole.Admin || user.Role === EUserRole.Manager) { - next(); - return; - } - - // Fetch deployment - const deployment = await Deployment.findByPk(deploymentId); +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; - if (!deployment) { - ResponseHelper.NotFound(res, 'Deployment not found'); - return; - } - - const deploymentData = deployment.toJSON(); - const projectId = deploymentData.ProjectId; +interface IRequestWithUser extends Request { + user?: IAccessSubject; +} - // Fetch associated project - const project = await Project.findByPk(projectId); +export class DeploymentAccessMiddleware { + private Guard(modify: boolean) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const user = (req as IRequestWithUser).user; + const deploymentIdParam = req.params.id; + + if (!user) { + ResponseHelper.Unauthorized(res, 'Authentication required'); + return; + } - if (!project) { - ResponseHelper.Error(res, 'Project not found for this deployment'); - return; - } + if (!deploymentIdParam) { + ResponseHelper.ValidationError(res, 'Deployment ID is required'); + return; + } - const projectData = project.toJSON(); + const deploymentId = AccessControlService.ParseId(deploymentIdParam); + if (deploymentId === null) { + ResponseHelper.ValidationError(res, 'Invalid deployment ID'); + return; + } - // Developer: Check project ownership - if (user.Role === EUserRole.Developer) { - if (projectData.CreatedBy !== user.UserId) { - Logger.Warn('Access denied: User does not own the project for this deployment', { + if (modify && user.Role === EUserRole.Viewer) { + Logger.Warn('Access denied: Viewers cannot modify deployments', { userId: user.UserId, deploymentId, - projectId: projectData.Id, - projectOwner: projectData.CreatedBy, }); - ResponseHelper.Forbidden(res, 'You do not have access to this deployment'); + ResponseHelper.Forbidden(res, 'Viewers cannot modify deployments'); return; } - next(); - return; - } - // Viewer: Read-only access - if (user.Role === EUserRole.Viewer) { - next(); - return; - } - - // Unknown role - ResponseHelper.Forbidden(res, 'Insufficient permissions'); - } catch (error) { - Logger.Error('Deployment access check failed', error as Error); - ResponseHelper.Error(res, 'Authorization failed'); - } - }; - - /** - * Check if user can modify/cancel/retry/rollback a deployment. - * - Admin + Manager: allowed - * - Developer: allowed if they own the project (CreatedBy === user) - * - Viewer: forbidden - */ - public CheckDeploymentModifyAccess = async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - try { - const user = (req as any).user; - const deploymentIdParam = req.params.id; - - if (!user) { - ResponseHelper.Unauthorized(res, 'Authentication required'); - return; - } - - if (!deploymentIdParam) { - ResponseHelper.ValidationError(res, 'Deployment ID is required'); - return; - } - - const deploymentId = parseInt(deploymentIdParam, 10); - - if (isNaN(deploymentId)) { - ResponseHelper.ValidationError(res, 'Invalid deployment ID'); - return; - } - - // Viewer cannot modify deployments - if (user.Role === EUserRole.Viewer) { - Logger.Warn('Access denied: Viewers cannot modify deployments', { - userId: user.UserId, - deploymentId, - }); - ResponseHelper.Forbidden(res, 'Viewers cannot modify deployments'); - return; - } - - // Admin + Manager have full modify access (Manager is the - // project-management role per the v3.0 RBAC matrix; without this - // branch, Manager would fall through to "Insufficient permissions", - // breaking rollback/retry/cancel for Manager-role users — explicitly - // promised in the F-007 contract). - if (user.Role === EUserRole.Admin || user.Role === EUserRole.Manager) { - next(); - return; - } - - // Fetch deployment - const deployment = await Deployment.findByPk(deploymentId); - - if (!deployment) { - ResponseHelper.NotFound(res, 'Deployment not found'); - return; - } - - const deploymentData = deployment.toJSON(); - const projectId = deploymentData.ProjectId; - - // Fetch associated project - const project = await Project.findByPk(projectId); + if (AccessControlService.IsStaff(user)) { + next(); + return; + } - if (!project) { - ResponseHelper.Error(res, 'Project not found for this deployment'); - return; - } + const projectId = await AccessControlService.GetDeploymentProjectId(deploymentId); + if (projectId === null) { + ResponseHelper.NotFound(res, 'Deployment not found'); + return; + } - const projectData = project.toJSON(); + const allowed = modify + ? await AccessControlService.CanModifyProject(user, projectId) + : await AccessControlService.CanViewProject(user, projectId); - // Developer: Check project ownership - if (user.Role === EUserRole.Developer) { - if (projectData.CreatedBy !== user.UserId) { - Logger.Warn('Access denied: User does not own the project for this deployment', { + if (!allowed) { + Logger.Warn('Access denied: user is not a member of the deployment project', { userId: user.UserId, deploymentId, - projectId: projectData.Id, - projectOwner: projectData.CreatedBy, + projectId, }); - ResponseHelper.Forbidden(res, 'You can only modify deployments for your own projects'); + ResponseHelper.Forbidden( + res, + modify + ? 'You can only modify deployments for your own projects' + : 'You do not have access to this deployment' + ); return; } + next(); - return; + } catch (error) { + Logger.Error('Deployment access check failed', error as Error); + ResponseHelper.Error(res, 'Authorization failed'); } + }; + } + + /** View a deployment (details, logs, log download). */ + public CheckDeploymentAccess = this.Guard(false); - // Unknown role - ResponseHelper.Forbidden(res, 'Insufficient permissions'); - } catch (error) { - Logger.Error('Deployment modify access check failed', error as Error); - ResponseHelper.Error(res, 'Authorization failed'); - } - }; + /** Cancel / retry / rollback a deployment. */ + public CheckDeploymentModifyAccess = this.Guard(true); } export default DeploymentAccessMiddleware; diff --git a/src/Middleware/ProjectAccessMiddleware.ts b/src/Middleware/ProjectAccessMiddleware.ts index d279c25..3405f1b 100644 --- a/src/Middleware/ProjectAccessMiddleware.ts +++ b/src/Middleware/ProjectAccessMiddleware.ts @@ -1,17 +1,15 @@ /** * Project Access Control Middleware - * Validates user access to specific projects based on roles and membership - * Following SOLID principles and PascalCase naming convention + * Validates user access to specific projects based on roles and membership. + * + * v3.0.1 (SEC-H4): every decision is delegated to AccessControlService so the + * HTTP layer and Socket.IO share one implementation. * * Access Control (STRICT ENFORCEMENT): - * - Admin: Full access to all projects - * - Manager: Full access to all projects (can assign members) - * - Developer: - * - Can create new projects (becomes owner) - * - Can delete ONLY projects they own (Role='owner' in ProjectMember) - * - Can modify projects they own OR are members of (Role='owner' or 'member') - * - Cannot delete projects they're only members of - * - Viewer: Read-only access to all projects + * - Admin / Manager: full access to all projects + * - Developer: view + modify projects they are a member of (or created); + * delete only projects they own (ProjectMember.Role = 'owner') + * - Viewer: read-only access to projects they are a member of */ import { Request, Response, NextFunction } from 'express'; @@ -19,284 +17,119 @@ import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; import { EUserRole } from '@Types/ICommon'; import { Project, ProjectMember } from '@Models/index'; +import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; + +type TProjectCheck = (subject: IAccessSubject, projectId: number) => Promise; + +interface IRequestWithUser extends Request { + user?: IAccessSubject; + projectMembership?: unknown; +} export class ProjectAccessMiddleware { /** - * Check if user has access to view a specific project - * - Admin/Manager: Full access to all projects - * - Developer: Access to projects they own or are members of - * - Viewer: Read-only access to all projects + * Shared flow: authenticate, parse the id, 404 for unknown projects + * (non-staff only, so ids cannot be probed), then apply `check`. */ - public CheckProjectAccess = async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - try { - const user = (req as any).user; - const projectIdParam = req.params.projectId || req.params.id; - - if (!user) { - ResponseHelper.Unauthorized(res, 'Authentication required'); - return; - } - - if (!projectIdParam) { - ResponseHelper.ValidationError(res, 'Project ID is required'); - return; - } - - const projectId = parseInt(projectIdParam, 10); - - if (isNaN(projectId)) { - ResponseHelper.ValidationError(res, 'Invalid project ID'); - return; - } - - // Admin and Manager have full access - if (user.Role === EUserRole.Admin || user.Role === EUserRole.Manager) { - next(); - return; - } - - // Verify project exists - const project = await Project.findByPk(projectId); - - if (!project) { - ResponseHelper.NotFound(res, 'Project not found'); - return; - } - - // Viewer: Read-only access to all projects - if (user.Role === EUserRole.Viewer) { - next(); - return; - } - - // Developer: Check if they are owner or member - if (user.Role === EUserRole.Developer) { - const membership = await ProjectMember.findOne({ - where: { - ProjectId: projectId, - UserId: user.UserId, - }, - }); - - if (!membership) { - Logger.Warn('Access denied: User is not a member of this project', { - userId: user.UserId, - projectId, - }); - ResponseHelper.Forbidden(res, 'You do not have access to this project'); + private Guard(check: TProjectCheck, deniedMessage: string, action: string) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const user = (req as IRequestWithUser).user; + const projectIdParam = req.params.projectId || req.params.id; + + if (!user) { + ResponseHelper.Unauthorized(res, 'Authentication required'); return; } - // Store membership info for later use in controllers - (req as any).projectMembership = membership.toJSON(); - next(); - return; - } - - // Unknown role - ResponseHelper.Forbidden(res, 'Insufficient permissions'); - } catch (error) { - Logger.Error('Project access check failed', error as Error); - ResponseHelper.Error(res, 'Authorization failed'); - } - }; - - /** - * Check if user can modify a project - * - Admin/Manager: Can modify all projects - * - Developer: Can modify projects they own OR are members of - * - Viewer: Cannot modify projects - */ - public CheckProjectModifyAccess = async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - try { - const user = (req as any).user; - const projectIdParam = req.params.projectId || req.params.id; - - if (!user) { - ResponseHelper.Unauthorized(res, 'Authentication required'); - return; - } - - if (!projectIdParam) { - ResponseHelper.ValidationError(res, 'Project ID is required'); - return; - } - - const projectId = parseInt(projectIdParam, 10); - - if (isNaN(projectId)) { - ResponseHelper.ValidationError(res, 'Invalid project ID'); - return; - } - - // Viewer cannot modify projects - if (user.Role === EUserRole.Viewer) { - Logger.Warn('Access denied: Viewers have read-only access', { - userId: user.UserId, - projectId, - }); - ResponseHelper.Forbidden(res, 'Viewers cannot modify projects'); - return; - } - - // Admin and Manager have full access - if (user.Role === EUserRole.Admin || user.Role === EUserRole.Manager) { - next(); - return; - } - - // Verify project exists - const project = await Project.findByPk(projectId); - - if (!project) { - ResponseHelper.NotFound(res, 'Project not found'); - return; - } - - // Developer: Check if they are owner or member - if (user.Role === EUserRole.Developer) { - const membership = await ProjectMember.findOne({ - where: { - ProjectId: projectId, - UserId: user.UserId, - }, - }); - - if (!membership) { - Logger.Warn('Access denied: User is not a member of this project', { - userId: user.UserId, - projectId, - }); - ResponseHelper.Forbidden(res, 'You can only modify projects you own or are a member of'); + if (!projectIdParam) { + ResponseHelper.ValidationError(res, 'Project ID is required'); return; } - // Store membership info for later use in controllers - (req as any).projectMembership = membership.toJSON(); - next(); - return; - } - - // Unknown role - ResponseHelper.Forbidden(res, 'Insufficient permissions'); - } catch (error) { - Logger.Error('Project modify access check failed', error as Error); - ResponseHelper.Error(res, 'Authorization failed'); - } - }; - - /** - * Check if user can delete a project (STRICT) - * - Admin/Manager: Can delete all projects - * - Developer: Can ONLY delete projects they OWN (Role='owner' in ProjectMember) - * - Members (Role='member') CANNOT delete projects - * - Viewer: Cannot delete projects - */ - public CheckProjectDeleteAccess = async ( - req: Request, - res: Response, - next: NextFunction - ): Promise => { - try { - const user = (req as any).user; - const projectIdParam = req.params.projectId || req.params.id; - - if (!user) { - ResponseHelper.Unauthorized(res, 'Authentication required'); - return; - } - - if (!projectIdParam) { - ResponseHelper.ValidationError(res, 'Project ID is required'); - return; - } - - const projectId = parseInt(projectIdParam, 10); - - if (isNaN(projectId)) { - ResponseHelper.ValidationError(res, 'Invalid project ID'); - return; - } - - // Viewer cannot delete projects - if (user.Role === EUserRole.Viewer) { - Logger.Warn('Access denied: Viewers cannot delete projects', { - userId: user.UserId, - projectId, - }); - ResponseHelper.Forbidden(res, 'Viewers cannot delete projects'); - return; - } - - // Admin and Manager have full access - if (user.Role === EUserRole.Admin || user.Role === EUserRole.Manager) { - next(); - return; - } - - // Verify project exists - const project = await Project.findByPk(projectId); - - if (!project) { - ResponseHelper.NotFound(res, 'Project not found'); - return; - } + const projectId = AccessControlService.ParseId(projectIdParam); + if (projectId === null) { + ResponseHelper.ValidationError(res, 'Invalid project ID'); + return; + } - // Developer: STRICT - Can ONLY delete if they are the OWNER - if (user.Role === EUserRole.Developer) { - const membership = await ProjectMember.findOne({ - where: { - ProjectId: projectId, - UserId: user.UserId, - }, - }); + if (AccessControlService.IsStaff(user)) { + next(); + return; + } - if (!membership) { - Logger.Warn('Access denied: User is not a member of this project', { + if (user.Role === EUserRole.Viewer && action !== 'view') { + Logger.Warn(`Access denied: Viewers cannot ${action} projects`, { userId: user.UserId, projectId, }); - ResponseHelper.Forbidden(res, 'You do not have access to this project'); + ResponseHelper.Forbidden(res, `Viewers cannot ${action} projects`); return; } - const membershipData = membership.toJSON(); + const project = await Project.findByPk(projectId, { attributes: ['Id'] }); + if (!project) { + ResponseHelper.NotFound(res, 'Project not found'); + return; + } - // STRICT: Only owners can delete - if (membershipData.Role !== 'owner') { - Logger.Warn('Access denied: Only project owners can delete projects', { + if (!(await check(user, projectId))) { + Logger.Warn(`Access denied: user may not ${action} this project`, { userId: user.UserId, projectId, - memberRole: membershipData.Role, }); - ResponseHelper.Forbidden( - res, - 'You can only delete projects you own. Members cannot delete projects.' - ); + ResponseHelper.Forbidden(res, deniedMessage); return; } - // Store membership info for later use in controllers - (req as any).projectMembership = membershipData; + const membership = await ProjectMember.findOne({ + where: { ProjectId: projectId, UserId: user.UserId }, + }); + if (membership) { + (req as IRequestWithUser).projectMembership = membership.toJSON(); + } next(); - return; + } catch (error) { + Logger.Error(`Project ${action} access check failed`, error as Error); + ResponseHelper.Error(res, 'Authorization failed'); } + }; + } + + /** + * Check if user may view a project (Viewer: membership required). + */ + public CheckProjectAccess = this.Guard( + (subject, projectId) => AccessControlService.CanViewProject(subject, projectId), + 'You do not have access to this project', + 'view' + ); + + /** + * Check if user can modify a project (never Viewer). + */ + public CheckProjectModifyAccess = this.Guard( + (subject, projectId) => AccessControlService.CanModifyProject(subject, projectId), + 'You can only modify projects you own or are a member of', + 'modify' + ); - // Unknown role - ResponseHelper.Forbidden(res, 'Insufficient permissions'); - } catch (error) { - Logger.Error('Project delete access check failed', error as Error); - ResponseHelper.Error(res, 'Authorization failed'); - } - }; + /** + * Check if user can delete a project (STRICT): Developers must be owners. + */ + public CheckProjectDeleteAccess = this.Guard( + async (subject, projectId) => { + if (subject.Role !== EUserRole.Developer) return false; + const membership = await ProjectMember.findOne({ + where: { ProjectId: projectId, UserId: subject.UserId }, + }); + if (membership) return membership.get('Role') === 'owner'; + const project = await Project.findByPk(projectId, { attributes: ['Id', 'CreatedBy'] }); + return Boolean(project && project.CreatedBy === subject.UserId); + }, + 'You can only delete projects you own. Members cannot delete projects.', + 'delete' + ); } export default ProjectAccessMiddleware; diff --git a/src/Routes/DeploymentRoutes.ts b/src/Routes/DeploymentRoutes.ts index 4902b75..15b8584 100644 --- a/src/Routes/DeploymentRoutes.ts +++ b/src/Routes/DeploymentRoutes.ts @@ -9,6 +9,7 @@ import DeploymentController from '@Controllers/DeploymentController'; import AuthMiddleware from '@Middleware/AuthMiddleware'; import RoleMiddleware from '@Middleware/RoleMiddleware'; import DeploymentAccessMiddleware from '@Middleware/DeploymentAccessMiddleware'; +import ProjectAccessMiddleware from '@Middleware/ProjectAccessMiddleware'; import RateLimiterMiddleware from '@Middleware/RateLimiterMiddleware'; import { RequireQueueReady } from '@Middleware/QueueReadyMiddleware'; @@ -18,6 +19,7 @@ export class DeploymentRoutes { private readonly AuthMiddleware: AuthMiddleware; private readonly RoleMiddleware: RoleMiddleware; private readonly DeploymentAccessMiddleware: DeploymentAccessMiddleware; + private readonly ProjectAccessMiddleware: ProjectAccessMiddleware; private readonly RateLimiter: RateLimiterMiddleware; constructor() { @@ -26,6 +28,7 @@ export class DeploymentRoutes { this.AuthMiddleware = new AuthMiddleware(); this.RoleMiddleware = new RoleMiddleware(); this.DeploymentAccessMiddleware = new DeploymentAccessMiddleware(); + this.ProjectAccessMiddleware = new ProjectAccessMiddleware(); this.RateLimiter = new RateLimiterMiddleware(); this.InitializeRoutes(); } @@ -84,6 +87,7 @@ export class DeploymentRoutes { this.Router.get( '/:id/log/download', this.AuthMiddleware.Authenticate, + this.DeploymentAccessMiddleware.CheckDeploymentAccess, // SEC-H4 this.RateLimiter.ApiLimiter, this.DeploymentController.DownloadDeploymentLog ); @@ -146,6 +150,7 @@ export class DeploymentRoutes { this.Router.get( '/projects/:projectId/deployments', this.AuthMiddleware.Authenticate, + this.ProjectAccessMiddleware.CheckProjectAccess, // SEC-H4 this.RateLimiter.ApiLimiter, this.DeploymentController.GetProjectDeployments ); @@ -158,6 +163,7 @@ export class DeploymentRoutes { '/projects/:projectId/deploy', this.AuthMiddleware.Authenticate, this.RoleMiddleware.RequireAdminManagerOrDeveloper, + this.ProjectAccessMiddleware.CheckProjectModifyAccess, // SEC-H4: members only this.RateLimiter.DeploymentLimiter, RequireQueueReady, // F-001 FR-005b — 503 if Redis down this.DeploymentController.CreateManualDeployment @@ -170,6 +176,7 @@ export class DeploymentRoutes { this.Router.get( '/projects/:projectId/queue/status', this.AuthMiddleware.Authenticate, + this.ProjectAccessMiddleware.CheckProjectAccess, // SEC-H4 this.RateLimiter.ApiLimiter, this.DeploymentController.GetProjectQueueStatus ); diff --git a/src/Routes/ProjectRoutes.ts b/src/Routes/ProjectRoutes.ts index 3c1222f..fb84d2d 100644 --- a/src/Routes/ProjectRoutes.ts +++ b/src/Routes/ProjectRoutes.ts @@ -161,7 +161,7 @@ export class ProjectRoutes { this.Router.post( '/:id/ssh-key', this.AuthMiddleware.Authenticate, - this.RoleMiddleware.RequireAdminOrDeveloper, + this.ProjectAccessMiddleware.CheckProjectModifyAccess, // SEC-H4: was role-only this.RateLimiter.ApiLimiter, this.ProjectController.GenerateSshKey ); @@ -173,7 +173,7 @@ export class ProjectRoutes { this.Router.put( '/:id/ssh-key', this.AuthMiddleware.Authenticate, - this.RoleMiddleware.RequireAdminOrDeveloper, + this.ProjectAccessMiddleware.CheckProjectModifyAccess, // SEC-H4: was role-only this.RateLimiter.ApiLimiter, this.ProjectController.RegenerateSshKey ); @@ -185,7 +185,7 @@ export class ProjectRoutes { this.Router.delete( '/:id/ssh-key', this.AuthMiddleware.Authenticate, - this.RoleMiddleware.RequireAdminOrDeveloper, + this.ProjectAccessMiddleware.CheckProjectModifyAccess, // SEC-H4: was role-only this.RateLimiter.ApiLimiter, this.ProjectController.DeleteSshKey ); @@ -197,6 +197,7 @@ export class ProjectRoutes { this.Router.get( '/:id/ssh-key', this.AuthMiddleware.Authenticate, + this.ProjectAccessMiddleware.CheckProjectAccess, // SEC-H4 this.RateLimiter.ApiLimiter, this.ProjectController.GetSshPublicKey ); @@ -208,7 +209,7 @@ export class ProjectRoutes { this.Router.patch( '/:id/ssh-key/toggle', this.AuthMiddleware.Authenticate, - this.RoleMiddleware.RequireAdminOrDeveloper, + this.ProjectAccessMiddleware.CheckProjectModifyAccess, // SEC-H4: was role-only this.RateLimiter.ApiLimiter, this.ProjectController.ToggleSshKeyUsage ); diff --git a/src/Routes/WebhookRoutes.ts b/src/Routes/WebhookRoutes.ts index d922621..6e03ac2 100644 --- a/src/Routes/WebhookRoutes.ts +++ b/src/Routes/WebhookRoutes.ts @@ -7,17 +7,20 @@ import { Router } from 'express'; import WebhookController from '@Controllers/WebhookController'; import RateLimiterMiddleware from '@Middleware/RateLimiterMiddleware'; +import AuthMiddleware from '@Middleware/AuthMiddleware'; import { RequireQueueReady } from '@Middleware/QueueReadyMiddleware'; export class WebhookRoutes { public Router: Router; private readonly WebhookController: WebhookController; private readonly RateLimiter: RateLimiterMiddleware; + private readonly Auth: AuthMiddleware; constructor() { this.Router = Router(); this.WebhookController = new WebhookController(); this.RateLimiter = new RateLimiterMiddleware(); + this.Auth = new AuthMiddleware(); this.InitializeRoutes(); } @@ -55,6 +58,7 @@ export class WebhookRoutes { this.Router.post( '/test/:projectName', this.RateLimiter.WebhookLimiter, + this.Auth.Authenticate, // SEC-H4: was public (project enumeration) this.WebhookController.TestWebhook ); @@ -65,6 +69,7 @@ export class WebhookRoutes { this.Router.get( '/test/:projectName', this.RateLimiter.WebhookLimiter, + this.Auth.Authenticate, // SEC-H4: was public (project enumeration) this.WebhookController.TestWebhook ); } diff --git a/src/Services/EnvironmentVariableService.ts b/src/Services/EnvironmentVariableService.ts index 9fed27a..52a76a2 100644 --- a/src/Services/EnvironmentVariableService.ts +++ b/src/Services/EnvironmentVariableService.ts @@ -27,6 +27,8 @@ export interface IEnvVarListItem { KeyName: string; Value: string; // "***" if IsSecret IsSecret: boolean; + /** v3.0.1 SEC-H3: secret values are write-only; this says whether one is set. */ + HasValue: boolean; CreatedAt: Date; UpdatedAt: Date; } @@ -53,6 +55,7 @@ export class EnvironmentVariableService { AuthTag: row.AuthTag, }), IsSecret: row.IsSecret, + HasValue: Boolean(row.ValueEncrypted), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, })); diff --git a/src/Services/ProjectService.ts b/src/Services/ProjectService.ts index ed310e6..33d4bcb 100644 --- a/src/Services/ProjectService.ts +++ b/src/Services/ProjectService.ts @@ -16,6 +16,7 @@ import { AssertSafePath, ParseRsyncOptions, } from '@Utils/SafeExec'; +import { MergeMaskedProjectConfig, SanitizeRepoUrl } from '@Utils/DtoMapper'; import AuditLogService from '@Services/AuditLogService'; import AppConfig from '@Config/AppConfig'; import { IProjectConfigJson } from '@Types/IDatabase'; @@ -147,6 +148,9 @@ export class ProjectService { */ public async CreateProject(data: ICreateProjectData, req: Request): Promise { try { + if (data.Config) { + data.Config = MergeMaskedProjectConfig(data.Config, {}); + } await ProjectService.ValidateExecutionInputs(data); // Check if project with same name exists @@ -210,6 +214,20 @@ export class ProjectService { throw new Error('Project not found'); } + // SEC-H3: secrets are write-only. Clients receive SECRET_MASK and a + // credential-free RepoUrl; when they send those back unchanged, keep + // the stored values instead of overwriting them with placeholders. + if (data.Config) { + data.Config = MergeMaskedProjectConfig(data.Config, project.Config); + } + if ( + data.RepoUrl !== undefined && + data.RepoUrl !== project.RepoUrl && + data.RepoUrl === SanitizeRepoUrl(project.RepoUrl) + ) { + data.RepoUrl = project.RepoUrl; + } + await ProjectService.ValidateExecutionInputs(data); // Check if name is being changed and if it conflicts diff --git a/src/Utils/DtoMapper.ts b/src/Utils/DtoMapper.ts index 00cb6f6..24720bb 100644 --- a/src/Utils/DtoMapper.ts +++ b/src/Utils/DtoMapper.ts @@ -100,4 +100,208 @@ export function ToDeploymentDto(deployment: unknown): TRow | null { return dto; } -export default { ToDeploymentDto, ToProjectSummaryDto, SanitizeRepoUrl }; +/** + * Placeholder sent instead of a secret value. When a client sends it back + * unchanged (e.g. a full-project PUT from an older UI), the stored value is + * kept: see MergeMaskedProjectConfig. + */ +export const SECRET_MASK = '********'; + +/** + * Legacy Config.Variables keys the platform itself defines (they mirror the + * named fields of IDeploymentContext). They are build settings, not secrets, + * and stay readable; every other variable value is write-only. + */ +const NON_SECRET_VARIABLES = new Set([ + 'BuildCommand', + 'BuildCmd', + 'BuildOutput', + 'TargetPath', + 'Target', + 'Pm2Name', + 'Type', +]); + +/** Secret leaves inside Config.Notifications: [channel, field]. */ +const NOTIFICATION_SECRET_FIELDS: Array<[string, string]> = [ + ['Discord', 'WebhookUrl'], + ['Slack', 'WebhookUrl'], + ['Email', 'SmtpPassword'], + ['Email', 'Password'], + ['Telegram', 'BotToken'], +]; + +const VARIABLE_BAGS = ['Variables', 'envVars'] as const; + +export interface ISecretState { + HasValue: boolean; +} + +function IsRecord(value: unknown): value is TRow { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function HasSecretValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== ''; +} + +/** + * Mask secrets inside a project Config. Returns the masked copy plus a flat + * map of secret paths → { HasValue } for clients that render "set / not set". + */ +export function MaskProjectConfig(config: unknown): { + Config: unknown; + Secrets: Record; +} { + const secrets: Record = {}; + let parsed: unknown = config; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return { Config: {}, Secrets: secrets }; + } + } + if (!IsRecord(parsed)) return { Config: parsed ?? null, Secrets: secrets }; + + const masked: TRow = JSON.parse(JSON.stringify(parsed)) as TRow; + + for (const bag of VARIABLE_BAGS) { + const vars = masked[bag]; + if (!IsRecord(vars)) continue; + for (const key of Object.keys(vars)) { + if (NON_SECRET_VARIABLES.has(key)) continue; + const hasValue = HasSecretValue(vars[key]); + secrets[`Config.${bag}.${key}`] = { HasValue: hasValue }; + vars[key] = hasValue ? SECRET_MASK : ''; + } + } + + const notifications = masked.Notifications; + if (IsRecord(notifications)) { + for (const [channel, field] of NOTIFICATION_SECRET_FIELDS) { + const target = notifications[channel]; + if (!IsRecord(target) || !(field in target)) continue; + const hasValue = HasSecretValue(target[field]); + secrets[`Config.Notifications.${channel}.${field}`] = { HasValue: hasValue }; + target[field] = hasValue ? SECRET_MASK : ''; + } + } + + return { Config: masked, Secrets: secrets }; +} + +/** + * Restore stored secrets wherever an incoming Config carries SECRET_MASK, so + * clients never need to know a secret to save the rest of a project. + */ +export function MergeMaskedProjectConfig(incoming: T, stored: unknown): T { + if (!IsRecord(incoming)) return incoming; + let current: unknown = stored; + if (typeof current === 'string') { + try { + current = JSON.parse(current); + } catch { + current = {}; + } + } + const previous = IsRecord(current) ? current : {}; + const merged: TRow = JSON.parse(JSON.stringify(incoming)) as TRow; + + for (const bag of VARIABLE_BAGS) { + const vars = merged[bag]; + const before = previous[bag]; + if (!IsRecord(vars)) continue; + for (const key of Object.keys(vars)) { + if (vars[key] === SECRET_MASK) { + if (IsRecord(before) && before[key] !== undefined) { + vars[key] = before[key]; + } else { + delete vars[key]; + } + } + } + } + + const notifications = merged.Notifications; + const previousNotifications = previous.Notifications; + if (IsRecord(notifications)) { + for (const [channel, field] of NOTIFICATION_SECRET_FIELDS) { + const target = notifications[channel]; + if (!IsRecord(target) || target[field] !== SECRET_MASK) continue; + const before = IsRecord(previousNotifications) ? previousNotifications[channel] : undefined; + target[field] = IsRecord(before) && before[field] !== undefined ? before[field] : ''; + } + } + + return merged as T; +} + +/** Columns that must never leave the server, whatever the endpoint. */ +const PROJECT_HIDDEN_FIELDS = ['WebhookSecret', 'SshKeyEncrypted', 'SshKeyIv', 'SshKeyAuthTag']; + +/** + * Full project as returned by the REST API (SEC-H3): webhook secret and the + * encrypted SSH private key are removed, Config secrets are masked, and a + * `Secrets` map reports which write-only values are set. + */ +export function ToProjectDto(project: unknown): TRow | null { + const row = ToPlain(project); + if (!row) return null; + const dto: TRow = { ...row }; + + const hasWebhookSecret = HasSecretValue(row.WebhookSecret); + const hasSshKey = HasSecretValue(row.SshKeyEncrypted); + for (const field of PROJECT_HIDDEN_FIELDS) delete dto[field]; + + if ('RepoUrl' in dto) dto.RepoUrl = SanitizeRepoUrl(dto.RepoUrl); + + // Only report state for columns that were actually loaded. + const secrets: Record = {}; + if ('WebhookSecret' in row) secrets.WebhookSecret = { HasValue: hasWebhookSecret }; + if ('SshKeyEncrypted' in row) secrets.SshPrivateKey = { HasValue: hasSshKey }; + if ('Config' in row) { + const masked = MaskProjectConfig(row.Config); + dto.Config = masked.Config; + Object.assign(secrets, masked.Secrets); + } + dto.Secrets = secrets; + + if (Array.isArray(row.Members)) { + dto.Members = row.Members.map((m) => ToPlain(m)); + } + return dto; +} + +/** + * Deployment as returned by the REST API: every column, steps as stored, and + * the embedded project passed through ToProjectDto. + */ +export function ToDeploymentResponseDto(deployment: unknown): TRow | null { + const row = ToPlain(deployment); + if (!row) return null; + const dto: TRow = { ...row }; + if (row.Project) dto.Project = ToProjectDto(row.Project); + return dto; +} + +/** User row without credential material (PasswordHash, legacy TOTP secret). */ +export function ToUserDto(user: unknown): TRow | null { + const row = ToPlain(user); + if (!row) return null; + const dto: TRow = { ...row }; + delete dto.PasswordHash; + delete dto.TwoFactorSecret; + return dto; +} + +export default { + ToUserDto, + ToDeploymentDto, + ToDeploymentResponseDto, + ToProjectDto, + ToProjectSummaryDto, + SanitizeRepoUrl, + MaskProjectConfig, + MergeMaskedProjectConfig, +}; From cd001ea896adae45c4a2505271227e92102ed81f Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:25:11 +0000 Subject: [PATCH 08/22] fix(config): refuse to start with default secrets in production (SEC-H2) - New ConfigValidator. With NODE_ENV=production the server exits at boot when JWT_SECRET, JWT_REFRESH_SECRET or ENCRYPTION_KEY is missing, shorter than 32 characters or a known default / placeholder, when the two JWT secrets are equal, or when DB_PASSWORD / DEFAULT_ADMIN_PASSWORD is a shipped placeholder. Outside production the same findings are logged as warnings. Messages never include secret values. - Real environment variables now take precedence over the .env file (including DB_AUTO_MIGRATE), so NODE_ENV=production from systemd, Docker or PM2 is honoured. - Unit tests for every rule and for the precedence order. --- .env.example | 4 + __tests__/unit/Config/ConfigValidator.test.ts | 118 ++++++++++++++++++ src/Config/AppConfig.ts | 15 ++- src/Config/ConfigValidator.ts | 110 ++++++++++++++++ src/Server.ts | 7 ++ 5 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 __tests__/unit/Config/ConfigValidator.test.ts create mode 100644 src/Config/ConfigValidator.ts diff --git a/.env.example b/.env.example index 271dd90..ac0ac4f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,10 @@ DB_FORCE_SYNC_ALTER=false DB_ROOT_PASSWORD=change_me_root_password # === JWT Configuration === +# REQUIRED in production (v3.0.1): the server refuses to start when +# JWT_SECRET, JWT_REFRESH_SECRET or ENCRYPTION_KEY is missing, shorter than +# 32 characters, or still a placeholder such as the values below. +# Real environment variables take precedence over this file. # Generate with: openssl rand -hex 64 JWT_SECRET=replace_with_64_byte_hex JWT_EXPIRY=7d diff --git a/__tests__/unit/Config/ConfigValidator.test.ts b/__tests__/unit/Config/ConfigValidator.test.ts new file mode 100644 index 0000000..170e3c9 --- /dev/null +++ b/__tests__/unit/Config/ConfigValidator.test.ts @@ -0,0 +1,118 @@ +/** + * Startup secret validation + env precedence — v3.0.1 / SEC-H2. + */ + +import { + AssertSecureConfiguration, + FindInsecureSettings, + InsecureConfigurationError, + IsPlaceholderSecret, +} from '@Config/ConfigValidator'; + +const strong = (seed: string): string => `${seed}`.repeat(8).slice(0, 64); + +function config(overrides: Partial<{ + Secret: string; + RefreshSecret: string; + EncryptionKey: string; + DbPassword: string; + AdminPassword: string; + Production: boolean; +}> = {}) { + return { + Jwt: { + Secret: overrides.Secret ?? strong('a1b2c3d4'), + RefreshSecret: overrides.RefreshSecret ?? strong('e5f6a7b8'), + Expiry: '15m', + RefreshExpiry: '7d', + }, + EncryptionKey: overrides.EncryptionKey ?? strong('0f1e2d3c'), + Database: { Password: overrides.DbPassword ?? 'K8s!real-db-pass' } as never, + DefaultAdmin: { Password: overrides.AdminPassword }, + IsProduction: () => overrides.Production ?? true, + }; +} + +describe('ConfigValidator', () => { + it('accepts strong, distinct secrets', () => { + expect(FindInsecureSettings(config())).toEqual([]); + expect(AssertSecureConfiguration(config())).toEqual([]); + }); + + it.each([ + ['JWT_SECRET', { Secret: 'change_this_secret_in_production' }], + ['JWT_SECRET', { Secret: '' }], + ['JWT_SECRET', { Secret: 'short-but-random' }], + ['JWT_REFRESH_SECRET', { RefreshSecret: 'change_this_refresh_secret' }], + ['JWT_REFRESH_SECRET', { RefreshSecret: 'replace_with_different_64_byte_hex' }], + ['ENCRYPTION_KEY', { EncryptionKey: 'change_this_encryption_key_32bytes' }], + ['ENCRYPTION_KEY', { EncryptionKey: 'replace_with_32_byte_hex' }], + ['DB_PASSWORD', { DbPassword: 'change_me_strong_password' }], + ['DEFAULT_ADMIN_PASSWORD', { AdminPassword: 'change_me_admin_password' }], + ])('flags %s', (name, overrides) => { + const problems = FindInsecureSettings(config(overrides)); + expect(problems.join(' ')).toContain(name); + }); + + it('requires different access and refresh secrets', () => { + const same = strong('9a8b7c6d'); + expect(FindInsecureSettings(config({ Secret: same, RefreshSecret: same }))).toContain( + 'JWT_REFRESH_SECRET must differ from JWT_SECRET' + ); + }); + + it('throws in production and only warns elsewhere', () => { + const insecure = { Secret: 'change_this_secret_in_production' }; + expect(() => AssertSecureConfiguration(config({ ...insecure, Production: true }))).toThrow( + InsecureConfigurationError + ); + expect(AssertSecureConfiguration(config({ ...insecure, Production: false })).length).toBe(1); + }); + + it('never puts secret values in the error message', () => { + const secret = 'replace_with_64_byte_hex'; + try { + AssertSecureConfiguration(config({ Secret: secret, Production: true })); + } catch (error) { + expect((error as Error).message).not.toContain(secret); + } + }); + + it('recognises placeholder shapes', () => { + expect(IsPlaceholderSecret('your_secret_here')).toBe(true); + expect(IsPlaceholderSecret('test_jwt_secret_not_for_production_use_only_in_tests_1234')).toBe(true); + expect(IsPlaceholderSecret(strong('5d4c3b2a'))).toBe(false); + }); +}); + +describe('AppConfig env precedence', () => { + const saved = { ...process.env }; + + afterEach(() => { + process.env = { ...saved }; + jest.resetModules(); + }); + + it('real environment variables override the .env file', () => { + process.env.NODE_ENV = 'production'; + delete process.env.PORT; + jest.isolateModules(() => { + jest.doMock('fs', () => { + const real = jest.requireActual('fs'); + return { + ...real, + existsSync: (p: string) => (String(p).endsWith('.env') ? true : real.existsSync(p)), + readFileSync: (p: string, ...rest: unknown[]) => + String(p).endsWith('.env') + ? Buffer.from('NODE_ENV=development\nPORT=4321\n') + : real.readFileSync(p, ...rest), + }; + }); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { AppConfig } = require('@Config/AppConfig'); + const cfg = AppConfig.GetInstance(); + expect(cfg.NodeEnv).toBe('production'); + expect(cfg.Port).toBe(4321); + }); + }); +}); diff --git a/src/Config/AppConfig.ts b/src/Config/AppConfig.ts index c2322d6..39070b4 100644 --- a/src/Config/AppConfig.ts +++ b/src/Config/AppConfig.ts @@ -18,9 +18,16 @@ if (fs.existsSync(envPath)) { _envVars = parsed; } -/** Get env variable from .env file (private) or process.env (system) */ +/** + * Resolve a setting: the real process environment (systemd, Docker, PM2, CI) + * wins over the .env file, which wins over the code default (SEC-H2). + * Previously .env won, so e.g. NODE_ENV=production from an orchestrator was + * silently ignored and cookies lost the Secure flag. + */ function getEnv(key: string, defaultValue: string = ''): string { - return _envVars[key] || process.env[key] || defaultValue; + const fromProcess = process.env[key]; + if (fromProcess !== undefined && fromProcess !== '') return fromProcess; + return _envVars[key] || defaultValue; } export class AppConfig { @@ -176,8 +183,8 @@ export class AppConfig { Idle: parseInt(getEnv('DB_POOL_IDLE', '10000'), 10), }, AutoMigrate: - _envVars.DB_AUTO_MIGRATE !== undefined - ? _envVars.DB_AUTO_MIGRATE === 'true' + getEnv('DB_AUTO_MIGRATE') !== '' + ? getEnv('DB_AUTO_MIGRATE') === 'true' : this.NodeEnv !== 'production', }; diff --git a/src/Config/ConfigValidator.ts b/src/Config/ConfigValidator.ts new file mode 100644 index 0000000..a830a94 --- /dev/null +++ b/src/Config/ConfigValidator.ts @@ -0,0 +1,110 @@ +/** + * ConfigValidator — refuse to boot in production with missing, default or + * placeholder secrets (v3.0.1, SEC-H2). + * + * Anyone who knows a default JWT secret can mint an Admin token, and a known + * encryption key decrypts every stored SSH key and env var, so these are + * fatal in production. Outside production the same findings are warnings. + */ + +import type { AppConfig } from './AppConfig'; + +/** Minimum length for HMAC / encryption secrets. */ +export const MIN_SECRET_LENGTH = 32; + +/** Defaults that shipped in code or in .env.example. */ +const KNOWN_DEFAULTS = new Set( + [ + 'change_this_secret_in_production', + 'change_this_refresh_secret', + 'change_this_encryption_key_32bytes', + 'change_this_session_secret', + 'replace_with_64_byte_hex', + 'replace_with_different_64_byte_hex', + 'replace_with_32_byte_hex', + 'replace_with_random_string', + 'replace_with_webhook_secret', + 'change_me_strong_password', + 'change_me_admin_password', + 'change_me_root_password', + 'future_central_deploy_2025', + ].map((v) => v.toLowerCase()) +); + +/** Shapes that are obviously placeholders even if not in the list above. */ +const PLACEHOLDER_PATTERNS = [ + /^change[_-]?(this|me)/i, + /^replace[_-]?with/i, + /^your[_-]/i, + /^(secret|password|changeme|default|example|test)$/i, + /not[_-]for[_-]production/i, +]; + +export function IsPlaceholderSecret(value: string | undefined | null): boolean { + if (!value) return true; + const normalized = value.trim().toLowerCase(); + if (KNOWN_DEFAULTS.has(normalized)) return true; + return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +type TSecureConfig = Pick; + +/** + * Return a human-readable problem per insecure setting (empty = all good). + * Never includes the secret values themselves. + */ +export function FindInsecureSettings(config: TSecureConfig): string[] { + const problems: string[] = []; + + const checkSecret = (name: string, value: string | undefined): void => { + if (!value) { + problems.push(`${name} is not set`); + } else if (IsPlaceholderSecret(value)) { + problems.push(`${name} is a default or placeholder value`); + } else if (value.length < MIN_SECRET_LENGTH) { + problems.push(`${name} must be at least ${MIN_SECRET_LENGTH} characters`); + } + }; + + checkSecret('JWT_SECRET', config.Jwt.Secret); + checkSecret('JWT_REFRESH_SECRET', config.Jwt.RefreshSecret); + if (config.Jwt.Secret && config.Jwt.Secret === config.Jwt.RefreshSecret) { + problems.push('JWT_REFRESH_SECRET must differ from JWT_SECRET'); + } + checkSecret('ENCRYPTION_KEY', config.EncryptionKey); + + if (config.Database.Password && IsPlaceholderSecret(config.Database.Password)) { + problems.push('DB_PASSWORD is a default or placeholder value'); + } + if (config.DefaultAdmin.Password && IsPlaceholderSecret(config.DefaultAdmin.Password)) { + problems.push('DEFAULT_ADMIN_PASSWORD is a default or placeholder value'); + } + + return problems; +} + +export class InsecureConfigurationError extends Error { + public readonly Problems: string[]; + constructor(problems: string[]) { + super( + `Refusing to start in production with insecure configuration: ${problems.join('; ')}. ` + + 'Generate secrets with `openssl rand -hex 64` (JWT) and `openssl rand -hex 32` (ENCRYPTION_KEY).' + ); + this.name = 'InsecureConfigurationError'; + this.Problems = problems; + } +} + +/** + * Throw in production when any secret is insecure; return the findings + * otherwise so callers can log them as warnings. + */ +export function AssertSecureConfiguration(config: TSecureConfig & { IsProduction(): boolean }): string[] { + const problems = FindInsecureSettings(config); + if (problems.length > 0 && config.IsProduction()) { + throw new InsecureConfigurationError(problems); + } + return problems; +} + +export default { FindInsecureSettings, AssertSecureConfiguration, IsPlaceholderSecret }; diff --git a/src/Server.ts b/src/Server.ts index c47353f..4dcfa2c 100644 --- a/src/Server.ts +++ b/src/Server.ts @@ -13,6 +13,7 @@ import SocketService from '@Services/SocketService'; import QueueService from '@Services/QueueService'; import { DeploymentService } from '@Services/DeploymentService'; import App from './App'; +import { AssertSecureConfiguration } from '@Config/ConfigValidator'; export class Server { private readonly Config: ReturnType; @@ -47,6 +48,12 @@ export class Server { port: this.Port, }); + // SEC-H2: fail closed on default / placeholder secrets in production. + const insecure = AssertSecureConfiguration(this.Config); + insecure.forEach((problem) => + Logger.Warn(`Insecure configuration (fatal in production): ${problem}`) + ); + // Initialize database await this.InitializeDatabase(); From c014315de433220740064674f6c82e0016516a6c Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:29:03 +0000 Subject: [PATCH 09/22] fix(build): patch vulnerable dependencies and stop deleting dist before compiling (OPS-C1, OPS-H1) - npm audit fix (no --force), nodemailer 10.2.1, and a qs override for the express copy nested under @bull-board/express. npm audit --omit=dev now reports 0 vulnerabilities; only dev-only deepmerge (via jest) remains, with no non-breaking fix. - engines.node >=20 (nodemailer 10 requirement; Node 18 is EOL). CI tests Node 20 and 22, and the security workflow fails on high or critical production advisories instead of continue-on-error. - npm run build compiles into a staging directory and swaps it into dist/ only after tsc succeeds; the prebuild rimraf is gone, so a failed build leaves the running code intact. - test:unit / test:integration use Jest 30's --testPathPatterns. --- .github/workflows/build-test.yml | 3 +- .github/workflows/security.yml | 7 +- .gitignore | 5 +- package-lock.json | 2437 +++++++++++++++--------------- package.json | 16 +- 5 files changed, 1276 insertions(+), 1192 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 788b283..785928a 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -13,7 +13,8 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x] + # Node 18 is EOL and nodemailer 10 requires >= 20 (v3.0.1). + node-version: [20.x, 22.x] services: mariadb: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index d4f7ac7..77e0e96 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -62,15 +62,14 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '18' + node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - - name: Run npm audit - run: npm audit --audit-level=moderate - continue-on-error: true + - name: Run npm audit (blocking for high / critical in production deps) + run: npm audit --omit=dev --audit-level=high - name: Check for known vulnerabilities run: npm audit --json > security-audit.json diff --git a/.gitignore b/.gitignore index 54c01e6..bc55c31 100644 --- a/.gitignore +++ b/.gitignore @@ -150,4 +150,7 @@ DEPLOYMENT_LOGS_OPTIMIZATION.md RSYNC_PERMISSIONS_FIX.md HUSKY_PRODUCTION_FIX.md PRODUCTION_DEPLOYMENT_FIXES.md -docs/spec-kit \ No newline at end of file +docs/spec-kit +# safe-build staging dirs (scripts/build/safe-build.js) +.dist-build-*/ +.dist-prev-*/ diff --git a/package-lock.json b/package-lock.json index 06818f3..8a10c24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "morgan": "^1.11.0", "mysql2": "^3.23.4", "node-cron": "^4.6.0", - "nodemailer": "^8.0.5", + "nodemailer": "^10.2.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "qrcode": "^1.5.4", @@ -74,7 +74,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=20.0.0", "npm": ">=9.0.0" } }, @@ -456,13 +456,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -471,9 +471,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -481,21 +481,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -522,14 +522,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -539,14 +539,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -566,9 +566,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -576,29 +576,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -618,9 +618,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", "engines": { @@ -628,9 +628,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -638,9 +638,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -648,27 +648,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -917,33 +917,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -951,14 +951,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -996,9 +996,9 @@ } }, "node_modules/@bull-board/express/node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -1009,7 +1009,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -1375,9 +1375,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1473,9 +1473,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1542,6 +1542,91 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1584,9 +1669,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1660,17 +1745,17 @@ } }, "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.5.2.tgz", + "integrity": "sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -1678,18 +1763,18 @@ } }, "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.5.2.tgz", + "integrity": "sha512-/jqvFWoJF7LV1a6AUpYMbMm+2yIYsHJfR474H0SCzr9k8tnO/jl9wRg6zSG6lxgf6EozNlCG4amFzWsbZSvvZA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/pattern": "30.5.0", + "@jest/reporters": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", @@ -1697,20 +1782,20 @@ "exit-x": "^0.2.2", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", + "jest-changed-files": "30.5.1", + "jest-config": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-resolve-dependencies": "30.5.2", + "jest-runner": "30.5.2", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "jest-watcher": "30.5.2", + "pretty-format": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -1726,9 +1811,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz", + "integrity": "sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==", "dev": true, "license": "MIT", "engines": { @@ -1736,70 +1821,70 @@ } }, "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.5.2.tgz", + "integrity": "sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1" + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" + "expect": "30.5.2", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.5.2.tgz", + "integrity": "sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0" + "@jest/get-type": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.5.2.tgz", + "integrity": "sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.5.0.tgz", + "integrity": "sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==", "dev": true, "license": "MIT", "engines": { @@ -1807,62 +1892,78 @@ } }, "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.5.2.tgz", + "integrity": "sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/types": "30.5.1", + "jest-mock": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.5.0.tgz", + "integrity": "sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.4.0" + "jest-regex-util": "30.5.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/react-is-19": { + "name": "react-is", + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz", + "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.5.2.tgz", + "integrity": "sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", + "@jest/console": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1880,9 +1981,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.5.0.tgz", + "integrity": "sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==", "dev": true, "license": "MIT", "dependencies": { @@ -1893,13 +1994,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz", + "integrity": "sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1909,14 +2010,15 @@ } }, "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.5.2.tgz", + "integrity": "sha512-c5CehvkbfHX6yyNg7bMn2ylqlp9AHXuZ/3hPH/Lh5bxD/vefd/lV9HBjMqXef9yQNqkdwyZvkLSVSwB38hy8Qw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", + "@jridgewell/trace-mapping": "^0.3.31", "callsites": "^3.1.0", + "convert-source-map": "^2.0.0", "graceful-fs": "^4.2.11" }, "engines": { @@ -1924,14 +2026,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.5.2.tgz", + "integrity": "sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/types": "30.5.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1940,15 +2042,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.5.2.tgz", + "integrity": "sha512-bmij8jZyYAC47ExT2GeP7PcrlwSNS+Bp3KeRuBH88gpcxqDv6fljN8PemS4J/lLZ79xb6mpaennQTTq0zUAVVg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", + "@jest/test-result": "30.5.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", + "jest-haste-map": "30.5.1", "slash": "^3.0.0" }, "engines": { @@ -1956,23 +2058,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.5.2.tgz", + "integrity": "sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", + "@jest/types": "30.5.1", + "@jridgewell/trace-mapping": "^0.3.31", + "babel-plugin-istanbul": "^8.0.0", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1982,14 +2084,14 @@ } }, "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.5.1.tgz", + "integrity": "sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", + "@jest/pattern": "30.5.0", + "@jest/schemas": "30.5.0", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -2129,22 +2231,25 @@ ] }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "node_modules/@noble/hashes": { @@ -2221,86 +2326,393 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=14" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">= 10.0.0" }, "funding": { - "url": "https://opencollective.com/pkgr" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@slack/types": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", - "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@slack/webhook": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@slack/webhook/-/webhook-7.1.0.tgz", - "integrity": "sha512-cVXjP1n5s9gF7EW0zi58gYhf9JQ+FDu9t0Tq4ufdJxSO6QkT4wBMjSWYGAz61Xi6eHaZlnyGu7RuzwpGERLYbw==", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@slack/types": "^2.20.1", - "@types/node": ">=18", - "axios": "^1.16.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@smithy/core": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", - "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/webhook": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@slack/webhook/-/webhook-7.1.0.tgz", + "integrity": "sha512-cVXjP1n5s9gF7EW0zi58gYhf9JQ+FDu9t0Tq4ufdJxSO6QkT4wBMjSWYGAz61Xi6eHaZlnyGu7RuzwpGERLYbw==", + "license": "MIT", + "dependencies": { + "@slack/types": "^2.20.1", + "@types/node": ">=18", + "axios": "^1.16.0" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.4.tgz", + "integrity": "sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2477,9 +2889,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, @@ -3544,16 +3956,12 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { @@ -3644,9 +4052,9 @@ } }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.21.0.tgz", + "integrity": "sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -3656,16 +4064,16 @@ } }, "node_modules/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.5.2.tgz", + "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.4.1", + "@jest/transform": "30.5.2", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -3678,9 +4086,9 @@ } }, "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, "license": "BSD-3-Clause", "workspaces": [ @@ -3691,16 +4099,16 @@ "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" + "test-exclude": "^7.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3738,20 +4146,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", + "babel-plugin-jest-hoist": "30.5.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, "node_modules/balanced-match": { @@ -3776,9 +4184,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", - "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3834,18 +4242,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -3858,9 +4266,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -3878,9 +4286,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3900,9 +4308,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "dev": true, "funding": [ { @@ -3920,11 +4328,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -3962,13 +4370,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/bullmq": { "version": "5.79.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.79.2.tgz", @@ -4052,9 +4453,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "dev": true, "funding": [ { @@ -4154,9 +4555,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", "dev": true, "license": "MIT" }, @@ -4175,100 +4576,37 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", - "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", "license": "MIT", "dependencies": { "color-convert": "^3.1.3", @@ -4373,14 +4711,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -4388,6 +4727,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -4640,8 +4983,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4780,9 +5123,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==", "dev": true, "license": "ISC" }, @@ -4800,10 +5143,9 @@ } }, "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, "node_modules/enabled": { @@ -4822,37 +5164,36 @@ } }, "node_modules/engine.io": { - "version": "6.6.8", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", - "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" } }, "node_modules/engine.io-client": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz", - "integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==", + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", "dev": true, "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.20.1", + "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, @@ -4893,6 +5234,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5036,20 +5384,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5070,19 +5408,6 @@ "node": "*" } }, - "node_modules/eslint/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -5212,18 +5537,18 @@ } }, "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.5.2.tgz", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5580,9 +5905,9 @@ } }, "node_modules/flat-cache/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5656,9 +5981,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", "funding": [ { "type": "individual", @@ -5878,22 +6203,18 @@ } }, "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -5912,25 +6233,40 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, - "node_modules/glob/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -6012,13 +6348,13 @@ "license": "MIT" }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -6274,9 +6610,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "license": "MIT", "engines": { "node": ">= 12" @@ -6509,16 +6845,16 @@ } }, "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.5.2.tgz", + "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/types": "30.5.1", "import-local": "^3.2.0", - "jest-cli": "30.4.2" + "jest-cli": "30.5.2" }, "bin": { "jest": "bin/jest.js" @@ -6536,14 +6872,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.5.1.tgz", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "p-limit": "^3.1.0" }, "engines": { @@ -6551,29 +6887,29 @@ } }, "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.5.2.tgz", + "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-each": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "p-limit": "^3.1.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -6583,21 +6919,21 @@ } }, "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.5.2.tgz", + "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/core": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-config": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "yargs": "^17.7.2" }, "bin": { @@ -6616,33 +6952,33 @@ } }, "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.5.2.tgz", + "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.2", + "@jest/types": "30.5.1", + "babel-jest": "30.5.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-circus": "30.5.2", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "parse-json": "^5.2.0", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -6667,25 +7003,25 @@ } }, "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.5.2.tgz", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.5.0.tgz", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { @@ -6696,70 +7032,87 @@ } }, "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.5.2.tgz", + "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.5.2.tgz", + "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" + "jest-mock": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.5.1.tgz", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" }, - "optionalDependencies": { - "fsevents": "^2.3.3" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6770,50 +7123,50 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.5.2.tgz", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", + "@jest/get-type": "30.5.0", "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.5.1.tgz", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "picomatch": "^4.0.3", - "pretty-format": "30.4.1", + "pretty-format": "30.5.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -6822,9 +7175,9 @@ } }, "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -6835,42 +7188,25 @@ } }, "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.5.2.tgz", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", - "jest-util": "30.4.1" + "jest-util": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, "node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "version": "30.5.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.5.0.tgz", + "integrity": "sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==", "dev": true, "license": "MIT", "engines": { @@ -6878,100 +7214,100 @@ } }, "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.5.1.tgz", + "integrity": "sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" + "unrs-resolver": "^1.12.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.2.tgz", + "integrity": "sha512-GMI0DP5enX9Z2qW2zFy1bOkrCfWAOPRJQ7qHt0pdfrxBPsgWxglMPrRSK2TX/wRWYxcPRSLFg3Z88qPOzHx7FQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" + "jest-regex-util": "30.5.0", + "jest-snapshot": "30.5.2" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.5.2.tgz", + "integrity": "sha512-/B6px7hiiNhVSRwuv9AGn0nawYvwsHeQZ3ItTd5Gp2diFd0EOKaWVeWqcbw3L88vx1GfpCKjQkxWItZCTl11Rw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/console": "30.5.2", + "@jest/environment": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", + "jest-haste-map": "30.5.1", + "jest-leak-detector": "30.5.1", + "jest-message-util": "30.5.1", + "jest-resolve": "30.5.1", + "jest-runtime": "30.5.2", + "jest-util": "30.5.1", + "jest-watcher": "30.5.2", + "jest-worker": "30.5.1", + "p-limit": "^3.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.5.2.tgz", + "integrity": "sha512-0paUAHBlheai10A3XSy9Xh+YBG0IROy+/qzcDPzQ/nbbXrznQzeWy5qniICWK6d3W3scaRQCVAbXdcK0ibBq4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/globals": "30.5.2", + "@jest/source-map": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", + "cjs-module-lexer": "^2.2.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", + "es-module-lexer": "^2.1.0", + "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", + "jest-haste-map": "30.5.1", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -6980,9 +7316,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.5.2.tgz", + "integrity": "sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==", "dev": true, "license": "MIT", "dependencies": { @@ -6991,20 +7327,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "@jest/snapshot-utils": "30.5.1", + "@jest/transform": "30.5.2", + "@jest/types": "30.5.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.4.1", + "expect": "30.5.2", "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", + "jest-diff": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-util": "30.5.1", + "pretty-format": "30.5.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -7013,13 +7349,13 @@ } }, "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.5.1.tgz", + "integrity": "sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.4.1", + "@jest/types": "30.5.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -7044,18 +7380,18 @@ } }, "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.5.1.tgz", + "integrity": "sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.4.1" + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -7075,19 +7411,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "version": "30.5.2", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.5.2.tgz", + "integrity": "sha512-rqRgg4R11GA9m1UsEZwQeixHsgZReCn6TOHAHtIH33yXrIvoX0OGPA20Yzzjkz7X40fZvcRSxEpQppYPcWzmcQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "string-length": "^4.0.2" }, "engines": { @@ -7095,15 +7431,15 @@ } }, "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.5.1.tgz", + "integrity": "sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", + "jest-util": "30.5.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -7128,9 +7464,9 @@ } }, "node_modules/joi": { - "version": "18.2.5", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.5.tgz", - "integrity": "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.3.0.tgz", + "integrity": "sha512-/RwAK4BAfzcdbdFg073tzq4/xhY6Q7lMd5AmjqTL6pu0QFJdEmtswgm8BvgwXvOKuPNYQ8XrdQrrW/gF65LiNw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/address": "^5.1.1", @@ -7153,10 +7489,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7478,20 +7824,10 @@ "dev": true, "license": "ISC" }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/mariadb": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", - "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.4.tgz", + "integrity": "sha512-3m0vdfgRkjle5/9hHl4R7rlVvnDA7QfuFSNkdIAQNROWKOS0J3b9IQK9HaZSCGEVKiDUbFXFiINfC+cmR5P/aA==", "license": "LGPL-2.1-or-later", "dependencies": { "@types/geojson": "^7946.0.16", @@ -7672,9 +8008,9 @@ } }, "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.31.0.tgz", + "integrity": "sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==", "license": "MIT", "engines": { "node": "*" @@ -7693,9 +8029,9 @@ } }, "node_modules/morgan": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", - "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.1.tgz", + "integrity": "sha512-tljKC0ex20AjO58Ob/eZ53JloycbVswbVNCHx6V6VLGzqt/w8dIynVGL0G8qVjNKwiA7sYSogCrN6QtJ82IV+g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", @@ -7798,12 +8134,13 @@ } }, "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz", + "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==", "dev": true, "license": "MIT", "bin": { + "napi-patch": "lib/patch.js", "napi-postinstall": "lib/cli.js" }, "engines": { @@ -7894,9 +8231,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", "dev": true, "license": "MIT", "engines": { @@ -7904,12 +8241,12 @@ } }, "node_modules/nodemailer": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.8.tgz", - "integrity": "sha512-p+XsnzXGdtIHXUu2ugxdfG+eX2nehsGhMjW9h0CWj1BhE30hrFz0kh0yIM0/VjUgVsRrDj+80ZO+I1nSkGE4tA==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz", + "integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==", "license": "MIT-0", "engines": { - "node": ">=6.0.0" + "node": ">=20.0.0" } }, "node_modules/nodemon": { @@ -7952,16 +8289,16 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/nodemon/node_modules/has-flag": { @@ -8491,16 +8828,16 @@ } }, "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "version": "30.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", + "integrity": "sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" + "@jest/react-is-18": "npm:react-is@^18.3.1", + "@jest/react-is-19": "npm:react-is@^19.2.5", + "@jest/schemas": "30.5.0", + "ansi-styles": "^5.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8520,9 +8857,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -8530,6 +8867,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -8592,15 +8933,6 @@ "node": ">=10.13.0" } }, - "node_modules/qrcode/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/qrcode/node_modules/cliui": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", @@ -8612,12 +8944,6 @@ "wrap-ansi": "^6.2.0" } }, - "node_modules/qrcode/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, "node_modules/qrcode/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -8670,32 +8996,6 @@ "node": ">=8" } }, - "node_modules/qrcode/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/qrcode/node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -8752,12 +9052,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", + "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -8820,22 +9121,6 @@ "node": ">= 0.10" } }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", - "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", - "dev": true, - "license": "MIT" - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -8978,63 +9263,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rndm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", @@ -9288,14 +9516,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -9307,13 +9535,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9414,13 +9642,13 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.20.1" + "ws": "~8.21.0" } }, "node_modules/socket.io-client": { @@ -9462,17 +9690,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/speakeasy": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", @@ -9577,45 +9794,18 @@ "node": ">=10" } }, - "node_modules/string-length/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-length/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/string-width-cjs": { @@ -9634,28 +9824,10 @@ "node": ">=8" } }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { + "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -9664,22 +9836,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", @@ -9694,16 +9850,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -9826,64 +9972,119 @@ } }, "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", "dev": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" + "glob": "^10.4.1", + "minimatch": "^10.2.2" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" }, "engines": { - "node": "*" + "node": ">=16 || 14 >=14.17" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/text-hex": { @@ -9899,13 +10100,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9967,19 +10161,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.15", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.15.tgz", + "integrity": "sha512-s2qmEUYFRqbjz1wv0EmccNPVOb45HOFpFuhzQJLKShYVisuTbCFPIIAIV2EuK5rIxb4BejLDbH7dnBZfYjybUQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", + "handlebars": "^4.7.10", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -9990,7 +10184,7 @@ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", + "@babel/core": ">=7.0.0-beta.0 <9", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", @@ -10307,9 +10501,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", - "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -10415,16 +10609,6 @@ "node": ">= 0.8" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10528,18 +10712,18 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -10564,64 +10748,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -10643,9 +10769,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10734,51 +10860,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index b5d7ac0..5727598 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "main": "dist/index.js", "scripts": { "dev": "nodemon --exec ts-node -r tsconfig-paths/register src/index.ts", - "build": "tsc", + "build": "node scripts/build/safe-build.js", "start": "NODE_ENV=production npm run build && node -r ./tsconfig-paths-bootstrap.js dist/index.js", "start:prod": "NODE_ENV=production npm run build && node dist/index.js", "test": "jest --coverage", - "test:unit": "jest --testPathPattern=unit", - "test:integration": "jest --testPathPattern=integration", + "test:unit": "jest --testPathPatterns=__tests__/unit", + "test:integration": "jest --testPathPatterns=__tests__/integration", "test:watch": "jest --watch", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", @@ -20,8 +20,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "clean": "rimraf dist", - "upgrade": "ncu -u && npm install", - "prebuild": "npm run clean" + "upgrade": "ncu -u && npm install" }, "keywords": [ "deployment", @@ -90,7 +89,7 @@ "morgan": "^1.11.0", "mysql2": "^3.23.4", "node-cron": "^4.6.0", - "nodemailer": "^8.0.5", + "nodemailer": "^10.2.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "qrcode": "^1.5.4", @@ -132,7 +131,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=18.0.0", + "node": ">=20.0.0", "npm": ">=9.0.0" }, "overrides": { @@ -140,6 +139,7 @@ "lodash": "^4.17.21", "validator": "^13.15.21", "dottie": "^2.0.4", - "ioredis": "$ioredis" + "ioredis": "$ioredis", + "qs": "^6.17.0" } } From b4647d6b75179328cfe76c6db9d8ffc077a0a189 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:29:29 +0000 Subject: [PATCH 10/22] fix(build): commit the safe build script (OPS-C1) The script was placed under scripts/build/, which the build/ ignore rule excluded, so the previous commit referenced a file that was not in the repository. Move it to scripts/safe-build.js. --- .gitignore | 2 +- package.json | 2 +- scripts/safe-build.js | 53 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 scripts/safe-build.js diff --git a/.gitignore b/.gitignore index bc55c31..9db6d92 100644 --- a/.gitignore +++ b/.gitignore @@ -151,6 +151,6 @@ RSYNC_PERMISSIONS_FIX.md HUSKY_PRODUCTION_FIX.md PRODUCTION_DEPLOYMENT_FIXES.md docs/spec-kit -# safe-build staging dirs (scripts/build/safe-build.js) +# safe-build staging dirs (scripts/safe-build.js) .dist-build-*/ .dist-prev-*/ diff --git a/package.json b/package.json index 5727598..a3a6c7f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "dev": "nodemon --exec ts-node -r tsconfig-paths/register src/index.ts", - "build": "node scripts/build/safe-build.js", + "build": "node scripts/safe-build.js", "start": "NODE_ENV=production npm run build && node -r ./tsconfig-paths-bootstrap.js dist/index.js", "start:prod": "NODE_ENV=production npm run build && node dist/index.js", "test": "jest --coverage", diff --git a/scripts/safe-build.js b/scripts/safe-build.js new file mode 100644 index 0000000..ccf55b1 --- /dev/null +++ b/scripts/safe-build.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Safe build (v3.0.1, OPS-C1). + * + * The old `prebuild: rimraf dist` deleted the running server's code before + * compiling, so a failed build (or `npm start` on a broken checkout) left the + * host with no dist at all. This script compiles into a temporary directory + * and only swaps it into place after tsc succeeds. On failure, dist is left + * exactly as it was. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const root = path.resolve(__dirname, '..'); +const dist = path.join(root, 'dist'); +const staging = path.join(root, `.dist-build-${process.pid}`); +const previous = path.join(root, `.dist-prev-${process.pid}`); + +function remove(target) { + fs.rmSync(target, { recursive: true, force: true }); +} + +remove(staging); + +const tsc = require.resolve('typescript/bin/tsc', { paths: [root] }); +const result = spawnSync( + process.execPath, + [tsc, '-p', path.join(root, 'tsconfig.json'), '--outDir', staging], + { cwd: root, stdio: 'inherit' } +); + +if (result.status !== 0) { + remove(staging); + process.stderr.write('[build] tsc failed; existing dist/ left untouched.\n'); + process.exit(result.status || 1); +} + +const hadDist = fs.existsSync(dist); +if (hadDist) fs.renameSync(dist, previous); +try { + fs.renameSync(staging, dist); +} catch (error) { + if (hadDist) fs.renameSync(previous, dist); + remove(staging); + process.stderr.write(`[build] could not install new build: ${error.message}\n`); + process.exit(1); +} +if (hadDist) remove(previous); +process.stdout.write('[build] dist/ updated.\n'); From 6c77e8dbfd115b5039682588de9eeeeb43c3f2c5 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:34:28 +0000 Subject: [PATCH 11/22] fix(db): boot on an empty database and accept legacy webhooks (OPS-C2, OPS-H2) OPS-C2: - Migration 000_initial_schema creates the full model schema when none of Users / Projects / Deployments exist, so migration 001 no longer fails with 'No description found for Projects' on a new install. On any existing database it is a no-op; every later migration was already idempotent and skips on the baseline schema. - InitializeAssociations is idempotent so the baseline can run from the boot path and the migrate CLI. - New CI job boots the real server in production mode on an empty MariaDB, checks /health and the first-admin setup state, then restarts it to prove the migrations are no-ops the second time. OPS-H2: - CSRF exemption is now route-based and POST-only for /api/webhooks/{github,gitlab,bitbucket}[/:name] and the v2.1 legacy /webhook/... mount, which used to return 403. Prefixes such as /api/webhooks/github-evil or deeper paths are no longer exempt. --- .github/workflows/build-test.yml | 105 ++++++++++++++++++ .../unit/Middleware/CsrfMiddleware.test.ts | 62 +++++++++++ src/Database/MigrationRunner.ts | 7 ++ src/Middleware/CsrfMiddleware.ts | 37 +++--- src/Migrations/000_initial_schema.ts | 56 ++++++++++ src/Models/index.ts | 7 ++ 6 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 __tests__/unit/Middleware/CsrfMiddleware.test.ts create mode 100644 src/Migrations/000_initial_schema.ts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 785928a..94fa2b1 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -95,3 +95,108 @@ jobs: - name: ❌ Build failure notification if: failure() run: echo "❌ Build or tests failed. Please check the logs." + + # v3.0.1 OPS-C2 — a brand-new install must boot on an EMPTY database. + # The test suite syncs models before migrating, which hid the fact that + # migration 001 failed on a fresh schema. This job runs the real server + # (production mode, generated secrets) against an empty MariaDB, then + # restarts it to prove every migration is a no-op on an existing schema. + fresh-install: + name: Boot on an empty database + runs-on: ubuntu-latest + + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root_password + MARIADB_DATABASE: deploy_center_fresh + MARIADB_USER: deploycenter + MARIADB_PASSWORD: deploycenter_fresh_password + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + NODE_ENV: production + PORT: '9090' + HOST: 127.0.0.1 + DB_HOST: 127.0.0.1 + DB_PORT: '3306' + DB_NAME: deploy_center_fresh + DB_USERNAME: deploycenter + DB_PASSWORD: deploycenter_fresh_password + DB_DIALECT: mysql + DB_AUTO_MIGRATE: 'true' + REDIS_HOST: 127.0.0.1 + REDIS_PORT: '6379' + CORS_ORIGIN: http://127.0.0.1:9090 + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v4 + + - name: 🔧 Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 22.x + cache: 'npm' + + - name: 📦 Install dependencies + run: npm ci + + - name: 🏗️ Build + run: npm run build + + - name: 🔑 Generate secrets + run: | + echo "JWT_SECRET=$(openssl rand -hex 64)" >> "$GITHUB_ENV" + echo "JWT_REFRESH_SECRET=$(openssl rand -hex 64)" >> "$GITHUB_ENV" + echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> "$GITHUB_ENV" + + - name: 🚀 First boot on the empty database + run: | + set -euo pipefail + boot() { + node -r ./tsconfig-paths-bootstrap.js dist/index.js > "server-$1.log" 2>&1 & + echo $! > server.pid + for i in $(seq 1 60); do + if curl -fsS -A deploy-center-ci -H 'X-Forwarded-Proto: https' \ + http://127.0.0.1:9090/health > /dev/null; then + return 0 + fi + if ! kill -0 "$(cat server.pid)" 2>/dev/null; then + cat "server-$1.log"; return 1 + fi + sleep 1 + done + cat "server-$1.log"; return 1 + } + boot first + status=$(curl -fsS -A deploy-center-ci -H 'X-Forwarded-Proto: https' \ + http://127.0.0.1:9090/api/auth/registration-status) + echo "$status" + echo "$status" | grep -q '"SetupRequired":true' + grep -q 'Migration 000: baseline schema created' server-first.log + kill "$(cat server.pid)"; sleep 3 + + boot second + grep -q 'Migration 000_initial_schema already executed' server-second.log + kill "$(cat server.pid)" + + - name: 📄 Server logs + if: always() + run: tail -n 200 server-*.log || true diff --git a/__tests__/unit/Middleware/CsrfMiddleware.test.ts b/__tests__/unit/Middleware/CsrfMiddleware.test.ts new file mode 100644 index 0000000..ed70a48 --- /dev/null +++ b/__tests__/unit/Middleware/CsrfMiddleware.test.ts @@ -0,0 +1,62 @@ +/** + * CSRF exemptions — v3.0.1 / OPS-H2. + * The legacy /webhook/github/:name route (used by existing GitHub hooks) must + * not be rejected by CSRF, while every other mutating route still is. + */ + +import express from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import CsrfMiddleware from '@Middleware/CsrfMiddleware'; + +function buildApp(): express.Application { + const app = express(); + const csrf = new CsrfMiddleware(); + app.use(express.json()); + app.use(cookieParser()); + app.use(csrf.GenerateToken); + app.use(csrf.VerifyToken); + app.all(/.*/, (_req, res) => { + res.status(200).json({ reached: true }); + }); + return app; +} + +describe('CsrfMiddleware exemptions — OPS-H2', () => { + it.each([ + '/webhook/github', + '/webhook/github/my-project', + '/api/webhooks/github', + '/api/webhooks/github/my-project', + ])('POST %s reaches the webhook handler without a CSRF token', async (url) => { + const res = await request(buildApp()).post(url).send({ zen: 'hi' }); + expect(res.status).toBe(200); + }); + + it.each([ + '/api/projects', + '/api/auth/register', + '/webhook/github/my-project/extra', + '/api/webhooks/github-evil', + '/api/webhooks/test/my-project', + '/webhookx/github/a', + ])('POST %s still requires a CSRF token', async (url) => { + const res = await request(buildApp()).post(url).send({}); + expect(res.status).toBe(403); + }); + + it('only POST is exempt on webhook routes', async () => { + const res = await request(buildApp()).delete('/webhook/github/my-project'); + expect(res.status).toBe(403); + }); + + it('accepts a mutating request with a valid token', async () => { + const app = buildApp(); + const agent = request.agent(app); + const first = await agent.get('/api/health'); + const raw = first.headers['set-cookie'] as unknown as string[]; + const token = raw.map((c) => c.split(';')[0]!).find((c) => c.startsWith('XSRF-TOKEN='))!.split('=')[1]!; + const res = await agent.post('/api/projects').set('X-XSRF-TOKEN', decodeURIComponent(token)).send({}); + expect(res.status).toBe(200); + }); +}); diff --git a/src/Database/MigrationRunner.ts b/src/Database/MigrationRunner.ts index f0679a5..22b40be 100644 --- a/src/Database/MigrationRunner.ts +++ b/src/Database/MigrationRunner.ts @@ -7,6 +7,7 @@ import { QueryInterface, QueryTypes } from 'sequelize'; import DatabaseConnection from './DatabaseConnection'; import Logger from '@Utils/Logger'; +import * as Migration000 from '@Migrations/000_initial_schema'; import * as Migration001 from '@Migrations/001_add_created_by_to_projects'; import * as Migration002 from '@Migrations/002_create_project_members'; import * as Migration003 from '@Migrations/003_create_project_audit_logs'; @@ -39,6 +40,12 @@ interface IMigration { export class MigrationRunner { private static readonly Migrations: IMigration[] = [ + { + // v3.0.1 OPS-C2 — baseline for empty databases; no-op on existing ones. + name: '000_initial_schema', + up: Migration000.up, + down: Migration000.down, + }, { name: '001_add_created_by_to_projects', up: Migration001.up, diff --git a/src/Middleware/CsrfMiddleware.ts b/src/Middleware/CsrfMiddleware.ts index 75fc8df..d2e35c9 100644 --- a/src/Middleware/CsrfMiddleware.ts +++ b/src/Middleware/CsrfMiddleware.ts @@ -16,27 +16,34 @@ export class CsrfMiddleware { private readonly HeaderName: string = 'X-XSRF-TOKEN'; private readonly Config = AppConfig; - // Paths that should be excluded from CSRF protection - // These are typically webhook endpoints that use HMAC signatures instead - private readonly ExcludedPaths: string[] = [ - '/api/webhooks/github', - '/api/webhooks/gitlab', - '/api/webhooks/bitbucket', - // v3.0 F-001 — Bull Board UI issues its own XHRs without our XSRF cookie; - // CSRF is unnecessary here because the route is gated by AuthMiddleware - // + RoleMiddleware(Admin) (see App.InitializeRoutes). - '/admin/queues', + /** + * Exact webhook routes exempt from CSRF (OPS-H2). Webhook deliveries are + * authenticated by their HMAC signature, not by cookies. Route-based (not + * path-prefix) so `/api/webhooks/github-anything` or deeper paths are not + * accidentally exempt. Covers the v3.0 `/api/webhooks/...` mount and the + * v2.1 legacy `/webhook/...` mount, which existing GitHub hooks still use. + */ + private readonly ExcludedWebhookRoutes: RegExp[] = [ + /^\/(?:api\/webhooks|webhook)\/(?:github|gitlab|bitbucket)(?:\/[^/]+)?\/?$/, ]; + // v3.0 F-001 — Bull Board UI issues its own XHRs without our XSRF cookie; + // CSRF is unnecessary here because the sub-tree is gated by AuthMiddleware + // + RoleMiddleware(Admin) (see App.InitializeRoutes). + private readonly ExcludedPrefixes: string[] = ['/admin/queues']; + constructor() { this.Tokens = new csrf(); } /** - * Check if the current request path should be excluded from CSRF protection + * Check if the current request should be excluded from CSRF protection */ - private IsPathExcluded(path: string): boolean { - return this.ExcludedPaths.some(excludedPath => path.startsWith(excludedPath)); + private IsPathExcluded(path: string, method: string = 'POST'): boolean { + if (this.ExcludedPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) { + return true; + } + return method === 'POST' && this.ExcludedWebhookRoutes.some((route) => route.test(path)); } /** @@ -45,7 +52,7 @@ export class CsrfMiddleware { public GenerateToken = (req: Request, res: Response, next: NextFunction): void => { try { // Skip CSRF token generation for excluded paths (webhooks) - if (this.IsPathExcluded(req.path)) { + if (this.IsPathExcluded(req.path, req.method)) { return next(); } @@ -92,7 +99,7 @@ export class CsrfMiddleware { public VerifyToken = (req: Request, res: Response, next: NextFunction): void => { try { // Skip CSRF check for excluded paths (webhooks with HMAC signatures) - if (this.IsPathExcluded(req.path)) { + if (this.IsPathExcluded(req.path, req.method)) { Logger.Debug('Skipping CSRF verification for webhook endpoint', { path: req.path }); return next(); } diff --git a/src/Migrations/000_initial_schema.ts b/src/Migrations/000_initial_schema.ts new file mode 100644 index 0000000..12c17fe --- /dev/null +++ b/src/Migrations/000_initial_schema.ts @@ -0,0 +1,56 @@ +/** + * Migration 000 (v3.0.1 — OPS-C2): baseline schema for fresh installs. + * + * The ALTER-style migrations 001+ assume the v2.1 tables already exist + * (historically created by sequelize.sync()), so on an empty database + * migration 001 failed with `No description found for "Projects" table` + * and a new install could not start. + * + * Behaviour: + * - Empty database (none of Users / Projects / Deployments exist): create + * the complete current schema from the models (`sync({ alter: false })`). + * Every later migration is idempotent and becomes a no-op on it. + * - Existing database: no-op. Nothing is created, altered or dropped. + * + * Because this baseline is generated from the models, every future migration + * must keep checking the current schema before changing it (as 001-022 do). + */ + +import { QueryInterface } from 'sequelize'; +import Logger from '@Utils/Logger'; +import { InitializeAssociations } from '@Models/index'; + +const CORE_TABLES = ['Users', 'Projects', 'Deployments']; + +async function existingTables(queryInterface: QueryInterface): Promise> { + const tables = (await queryInterface.showAllTables()) as unknown[]; + return new Set( + tables + .map((t) => (typeof t === 'string' ? t : (t as { tableName?: string }).tableName)) + .filter((name): name is string => typeof name === 'string') + .map((name) => name.toLowerCase()) + ); +} + +export const up = async (queryInterface: QueryInterface): Promise => { + const tables = await existingTables(queryInterface); + if (CORE_TABLES.some((table) => tables.has(table.toLowerCase()))) { + Logger.Info('Migration 000: existing schema detected, baseline skipped'); + return; + } + + InitializeAssociations(); + await queryInterface.sequelize.sync({ alter: false }); + Logger.Info('Migration 000: baseline schema created on empty database'); +}; + +/** + * Rolling back the baseline drops every model table and therefore all data. + * It only exists so the runner can undo a fresh install; never run it on a + * database that holds real data. + */ +export const down = async (queryInterface: QueryInterface): Promise => { + InitializeAssociations(); + await queryInterface.sequelize.drop(); + Logger.Warn('Migration 000: baseline rolled back (all model tables dropped)'); +}; diff --git a/src/Models/index.ts b/src/Models/index.ts index 38b6753..5af300d 100644 --- a/src/Models/index.ts +++ b/src/Models/index.ts @@ -26,7 +26,14 @@ import MfaChallenge from './MfaChallenge'; // v3.0.1 SEC-C3 /** * Define Model Associations */ +let associationsInitialized = false; + export function InitializeAssociations(): void { + // Idempotent: the boot path, the migrate CLI and the 000 baseline may all + // call this; defining an association twice throws in Sequelize. + if (associationsInitialized) return; + associationsInitialized = true; + // User <-> Deployment (One to Many) User.hasMany(Deployment, { foreignKey: 'TriggeredBy', From 2e2d01b2dc1db7f6f13be62201dbab4e74caf470 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:48:33 +0000 Subject: [PATCH 12/22] fix(auth): keep UserId in the 2FA challenge for the bundled UI (SEC-C3) The v3.0 UI served from public/ refuses to submit a 2FA code unless the login challenge contains a user id, which broke 2FA logins through it. The id is returned again for compatibility only: /verify-2fa still derives the user from the mfa_pending cookie and ignores any body UserId, and the tests keep asserting that. --- __tests__/integration/TwoFactorLogin.test.ts | 3 ++- src/Controllers/AuthController.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/__tests__/integration/TwoFactorLogin.test.ts b/__tests__/integration/TwoFactorLogin.test.ts index 1416a35..84d3c1e 100644 --- a/__tests__/integration/TwoFactorLogin.test.ts +++ b/__tests__/integration/TwoFactorLogin.test.ts @@ -84,7 +84,8 @@ describe('Two-factor login — SEC-C3', () => { expect(res.status).toBe(200); expect(res.body.Data.TwoFactorRequired).toBe(true); - expect(res.body.Data.UserId).toBeUndefined(); + // Kept for the bundled v3.0 UI; it carries no authority (see below). + expect(res.body.Data.UserId).toBe(user.Id); const cookies = cookieHeader(res); expect(cookies).toMatch(/mfa_pending=[^;]+/); expect(cookies).toMatch(/HttpOnly/i); diff --git a/src/Controllers/AuthController.ts b/src/Controllers/AuthController.ts index 9d2fdf0..9f3cac4 100644 --- a/src/Controllers/AuthController.ts +++ b/src/Controllers/AuthController.ts @@ -225,10 +225,13 @@ export class AuthController { const userData = result.User.toJSON(); if (result.TwoFactorRequired && result.MfaToken) { - // No user id in the body: the second step is bound to this cookie. + // The second step is bound to this cookie. UserId stays in the body + // only for compatibility with the bundled v3.0 UI, which refuses to + // submit a code without it; /verify-2fa never reads it. this.SetMfaCookie(res, result.MfaToken); ResponseHelper.Success(res, 'Two-factor verification required', { TwoFactorRequired: true, + UserId: userData.Id, Username: userData.Username, ExpiresInSeconds: Math.floor(MFA_CHALLENGE_TTL_MS / 1000), }); From 921f2bfb1e5f77784c42ab081036749124c78348 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:48:33 +0000 Subject: [PATCH 13/22] feat(ops): add a CLI to rotate project webhook secrets npm run rotate:webhook-secrets -- --all | --only [--out FILE] [--dry-run]. New secrets are written only to a 0600 file that is never overwritten; the terminal and logs list project names, not secrets. Each rotation is recorded in the audit log as webhook_secret_rotated. Also runnable from dist/ on hosts without dev dependencies. --- .../integration/WebhookSecretRotation.test.ts | 80 +++++++++++++++++ package.json | 5 +- src/Services/WebhookSecretRotationService.ts | 81 +++++++++++++++++ src/scripts/rotate-webhook-secrets.ts | 88 +++++++++++++++++++ 4 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 __tests__/integration/WebhookSecretRotation.test.ts create mode 100644 src/Services/WebhookSecretRotationService.ts create mode 100644 src/scripts/rotate-webhook-secrets.ts diff --git a/__tests__/integration/WebhookSecretRotation.test.ts b/__tests__/integration/WebhookSecretRotation.test.ts new file mode 100644 index 0000000..88763ac --- /dev/null +++ b/__tests__/integration/WebhookSecretRotation.test.ts @@ -0,0 +1,80 @@ +/** + * Webhook secret rotation helper — v3.0.1 (post-SEC-C2 operational step). + */ + +import { setupTestDb, teardownTestDb, truncateAll } from '../helpers/setupTestDb'; +import { makeProject } from '../helpers/factories'; +import WebhookSecretRotationService, { + WEBHOOK_SECRET_ROTATED, +} from '@Services/WebhookSecretRotationService'; +import { AuditLog, Project } from '@Models/index'; + +async function dbReachable(): Promise { + try { + await setupTestDb(); + return true; + } catch { + return false; + } +} + +describe('WebhookSecretRotationService', () => { + let dbUp = false; + + beforeAll(async () => { + dbUp = await dbReachable(); + }); + + afterAll(async () => { + if (dbUp) await teardownTestDb(); + }); + + beforeEach(async () => { + if (dbUp) await truncateAll(); + }); + + const secretOf = async (id: number): Promise => + (await Project.findByPk(id))!.get('WebhookSecret') as string; + + it('rotates a single project by id or name and audits it', async () => { + if (!dbUp) return; + const a = await makeProject(); + const b = await makeProject(); + const [beforeA, beforeB] = [await secretOf(a.Id), await secretOf(b.Id)]; + + const byId = await WebhookSecretRotationService.Rotate({ Project: String(a.Id) }); + expect(byId).toHaveLength(1); + expect(byId[0]!.WebhookSecret).toMatch(/^[0-9a-f]{64}$/); + expect(await secretOf(a.Id)).toBe(byId[0]!.WebhookSecret); + expect(await secretOf(a.Id)).not.toBe(beforeA); + expect(await secretOf(b.Id)).toBe(beforeB); + + await WebhookSecretRotationService.Rotate({ Project: b.Name }); + expect(await secretOf(b.Id)).not.toBe(beforeB); + expect(await AuditLog.count({ where: { Action: WEBHOOK_SECRET_ROTATED } })).toBe(2); + }); + + it('rotates every project with distinct secrets', async () => { + if (!dbUp) return; + await makeProject(); + await makeProject(); + const rotated = await WebhookSecretRotationService.Rotate({ All: true }); + expect(rotated).toHaveLength(2); + expect(new Set(rotated.map((r) => r.WebhookSecret)).size).toBe(2); + }); + + it('dry run changes nothing', async () => { + if (!dbUp) return; + const a = await makeProject(); + const before = await secretOf(a.Id); + const result = await WebhookSecretRotationService.Rotate({ All: true, DryRun: true }); + expect(result[0]!.WebhookSecret).toBeNull(); + expect(await secretOf(a.Id)).toBe(before); + }); + + it('requires an explicit target and a match', async () => { + if (!dbUp) return; + await expect(WebhookSecretRotationService.Rotate({})).rejects.toThrow(); + await expect(WebhookSecretRotationService.Rotate({ Project: 'nope' })).rejects.toThrow('No matching'); + }); +}); diff --git a/package.json b/package.json index a3a6c7f..13fc129 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deploy-center-server", - "version": "3.0.0", + "version": "3.0.1", "description": "Comprehensive Deployment Platform - Backend API", "main": "dist/index.js", "scripts": { @@ -20,7 +20,8 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "clean": "rimraf dist", - "upgrade": "ncu -u && npm install" + "upgrade": "ncu -u && npm install", + "rotate:webhook-secrets": "ts-node -r tsconfig-paths/register src/scripts/rotate-webhook-secrets.ts" }, "keywords": [ "deployment", diff --git a/src/Services/WebhookSecretRotationService.ts b/src/Services/WebhookSecretRotationService.ts new file mode 100644 index 0000000..0926a7c --- /dev/null +++ b/src/Services/WebhookSecretRotationService.ts @@ -0,0 +1,81 @@ +/** + * WebhookSecretRotationService — v3.0.1 operational helper. + * + * Before v3.0.1 every project's webhook secret was broadcast over an + * unauthenticated Socket.IO connection (SEC-C2), so operators must assume + * they leaked and rotate them. Used by `npm run rotate:webhook-secrets`. + */ + +import crypto from 'crypto'; +import { Op, WhereOptions } from 'sequelize'; +import { AuditLog, Project } from '@Models/index'; +import Logger from '@Utils/Logger'; + +/** AuditLog action recorded for every CLI rotation. */ +export const WEBHOOK_SECRET_ROTATED = 'webhook_secret_rotated'; + +export interface IRotationTarget { + All?: boolean; + /** Project id or exact name. */ + Project?: string; + DryRun?: boolean; +} + +export interface IRotatedSecret { + ProjectId: number; + Name: string; + WebhookPath: string; + WebhookSecret: string | null; +} + +export class WebhookSecretRotationService { + public static NewSecret(): string { + return crypto.randomBytes(32).toString('hex'); + } + + public static async Rotate(target: IRotationTarget): Promise { + if (!target.All && !target.Project) { + throw new Error('Choose --all or --only '); + } + + let where: WhereOptions = {}; + if (!target.All && target.Project) { + const asId = /^[1-9][0-9]*$/.test(target.Project) ? Number(target.Project) : null; + where = asId !== null + ? { [Op.or]: [{ Id: asId }, { Name: target.Project }] } + : { Name: target.Project }; + } + + const projects = await Project.findAll({ where, order: [['Id', 'ASC']] }); + if (projects.length === 0) { + throw new Error('No matching projects'); + } + + const rotated: IRotatedSecret[] = []; + for (const project of projects) { + const secret = target.DryRun ? null : WebhookSecretRotationService.NewSecret(); + if (secret) { + await project.update({ WebhookSecret: secret }); + await AuditLog.create({ + Action: WEBHOOK_SECRET_ROTATED, + ResourceType: 'project', + ResourceId: project.Id, + Details: { source: 'cli', projectName: project.Name }, + }); + } + rotated.push({ + ProjectId: project.Id, + Name: project.Name, + WebhookPath: `/api/webhooks/github/${project.Name}`, + WebhookSecret: secret, + }); + } + + Logger.Info(`Webhook secrets ${target.DryRun ? 'selected (dry run)' : 'rotated'}`, { + projectIds: rotated.map((r) => r.ProjectId), + }); + return rotated; + } +} + +export default WebhookSecretRotationService; diff --git a/src/scripts/rotate-webhook-secrets.ts b/src/scripts/rotate-webhook-secrets.ts new file mode 100644 index 0000000..d8bb060 --- /dev/null +++ b/src/scripts/rotate-webhook-secrets.ts @@ -0,0 +1,88 @@ +/** + * Rotate project webhook secrets — v3.0.1 operational CLI. + * + * Usage: + * npm run rotate:webhook-secrets -- --all [--out FILE] + * npm run rotate:webhook-secrets -- --only [--out FILE] + * npm run rotate:webhook-secrets -- --all --dry-run + * + * New secrets are written ONLY to the output file (mode 0600, never + * overwritten); the terminal and the logs show project names, not secrets. + * Paste each secret into the matching GitHub webhook, then delete the file. + * + * Exit code: 0 = success, 1 = error. + */ + +import 'tsconfig-paths/register'; +import fs from 'fs'; +import path from 'path'; +import DatabaseConnection from '@Database/DatabaseConnection'; +import WebhookSecretRotationService from '@Services/WebhookSecretRotationService'; + +interface IArgs { + All: boolean; + Project?: string; + DryRun: boolean; + Out: string; +} + +function ParseArgs(argv: string[]): IArgs { + const args: IArgs = { + All: false, + DryRun: false, + Out: path.resolve(process.cwd(), `webhook-secrets-${new Date().toISOString().replace(/[:.]/g, '-')}.json`), + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--all') args.All = true; + else if (arg === '--dry-run') args.DryRun = true; + // Not --project: tsconfig-paths/register reads that flag as a tsconfig path. + else if (arg === '--only') args.Project = argv[(i += 1)]; + else if (arg === '--out') args.Out = path.resolve(argv[(i += 1)] ?? ''); + else throw new Error(`Unknown argument: ${arg}`); + } + if (args.All === Boolean(args.Project)) { + throw new Error('Use exactly one of --all or --only '); + } + return args; +} + +function Write(line: string): void { + process.stdout.write(`${line}\n`); +} + +async function Main(): Promise { + const args = ParseArgs(process.argv.slice(2)); + if (!args.DryRun && fs.existsSync(args.Out)) { + throw new Error(`Refusing to overwrite existing file: ${args.Out}`); + } + + DatabaseConnection.GetInstance(); + await DatabaseConnection.TestConnection(); + + const rotated = await WebhookSecretRotationService.Rotate({ + All: args.All, + Project: args.Project, + DryRun: args.DryRun, + }); + + if (!args.DryRun) { + fs.writeFileSync(args.Out, `${JSON.stringify(rotated, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + } + + Write(args.DryRun ? 'Dry run, would rotate:' : 'Rotated webhook secrets for:'); + rotated.forEach((r) => Write(` #${r.ProjectId} ${r.Name} (${r.WebhookPath})`)); + if (!args.DryRun) { + Write(`New secrets written to ${args.Out} (mode 0600).`); + Write('Update each GitHub webhook with its new secret, then delete that file.'); + } + + await DatabaseConnection.CloseConnection(); +} + +Main() + .then(() => process.exit(0)) + .catch((error: Error) => { + process.stderr.write(`rotate-webhook-secrets failed: ${error.message}\n`); + process.exit(1); + }); From 24c53d3f9dba8fba972687f8c723067a027a780b Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Tue, 27 Oct 2026 23:48:33 +0000 Subject: [PATCH 14/22] docs: add the 3.0.1 security hotfix operator guide and bump to 3.0.1 - docs/guides/security-hotfix-3.0.1.md: what changed, breaking changes, upgrade steps, mandatory rotation of webhook secrets, JWT secrets and SSH deploy keys, and known gaps (UI-H0). - CHANGELOG entry for 3.0.1 by finding category. - Version 3.0.1. --- docs/CHANGELOG.md | 61 +++++++ docs/guides/README.md | 4 + docs/guides/security-hotfix-3.0.1.md | 250 +++++++++++++++++++++++++++ package-lock.json | 4 +- 4 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 docs/guides/security-hotfix-3.0.1.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f42bb10..d09dc98 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,67 @@ --- +## [3.0.1] — 2026-10-27 + +**Status:** 🟢 Released +**Theme:** Security hotfix. Upgrade promptly and complete the mandatory +secret rotation in +[guides/security-hotfix-3.0.1.md](./guides/security-hotfix-3.0.1.md). + +### Security + +- **SEC-C1 — Account registration.** Self-registration is closed by default + (`ALLOW_REGISTRATION=false`); the first account on an empty system becomes + Admin. Client-supplied roles are ignored and every `/api/auth` body is + schema-validated. +- **SEC-C2 — Real-time channel.** Socket.IO connections are authenticated, + room joins are authorized like the HTTP API, and events carry minimal + data-transfer objects instead of database rows. +- **SEC-C3 — Two-factor authentication.** The 2FA step is bound to the + password step (short-lived single-use cookie), with per-account lockout and + stronger, salted backup codes. +- **ENG-C3 — Pipeline conditions.** `RunIf` is evaluated by a safe parser; + unparseable conditions skip the step. +- **ENG-C2 — Command execution.** git, rsync, chown and ssh-keygen run + without a shell; refs, repository URLs and rsync options are validated. +- **SEC-H4 / SEC-H3 — Authorization and secret exposure.** Project + membership is enforced on every project and deployment route; secrets are + write-only in API responses (`HasValue`). +- **SEC-H2 — Configuration.** Production refuses to start with default or + placeholder secrets; the process environment overrides `.env`. +- **OPS-H1 — Dependencies.** Vulnerable dependencies updated (including + nodemailer 10); the CI audit gate is blocking for high/critical. + +### Fixed + +- **OPS-C1 — Build.** Duplicate ioredis versions no longer break `tsc`; + `npm run build` keeps the existing `dist/` unless compilation succeeds; + Jest 30 test scripts. +- **OPS-C2 — Fresh installs.** Baseline migration `000_initial_schema` lets a + new installation boot on an empty database; new CI job verifies it. +- **OPS-H2 — Legacy webhooks.** `/webhook/github/:project` is accepted again. + +### Added + +- `GET /api/auth/registration-status`. +- `npm run rotate:webhook-secrets` to rotate project webhook secrets. +- Migration `022_create_mfa_challenges_and_2fa_lockout`. + +### Breaking changes + +Registration closed by default; secrets are write-only; new 2FA flow and +invalidated pre-3.0.1 backup codes; Viewers limited to member projects; +stricter repository URL / branch / rsync validation; production refuses +insecure secrets; Node.js >= 20. Details and upgrade steps: +[guides/security-hotfix-3.0.1.md](./guides/security-hotfix-3.0.1.md). + +### Known gaps + +- UI-H0: the v3.0 client source is not in the client repository, so the + bundled UI could not be rebuilt with the client-side changes. + +--- + ## [3.0.0] — 2026-05-24 **Status:** 🟢 Released diff --git a/docs/guides/README.md b/docs/guides/README.md index d20b66d..f7e90a2 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -11,6 +11,10 @@ log download, and a CI pipeline. The guides below reflect v3.0 behavior; see [CHANGELOG.md](../CHANGELOG.md) for the full release history and [migration-v2-to-v3.md](../migration-v2-to-v3.md) for the upgrade path. +> ⚠️ **Running 3.0.0 or earlier?** Upgrade to **3.0.1** and follow the +> mandatory secret-rotation steps in +> [Security Hotfix 3.0.1 — Operator Guide](./security-hotfix-3.0.1.md). + ## 📚 Available Guides ### Getting Started diff --git a/docs/guides/security-hotfix-3.0.1.md b/docs/guides/security-hotfix-3.0.1.md new file mode 100644 index 0000000..0b50036 --- /dev/null +++ b/docs/guides/security-hotfix-3.0.1.md @@ -0,0 +1,250 @@ +# Deploy Center 3.0.1 — Security Hotfix: Operator Guide + +**Audience:** anyone running Deploy Center 3.0.0 (or 2.1.x) in any environment. +**Urgency:** upgrade as soon as possible, then complete the rotation steps +below. The rotation steps are **mandatory** even after upgrading: the fixed +issues allowed secrets to leak, and upgrading does not un-leak them. + +This guide describes what changed and what you must do. It deliberately does +not describe how the old issues could be exploited. + +--- + +## 1. What changed + +| Area | Change | +| --- | --- | +| Registration | Public sign-up is closed by default (`ALLOW_REGISTRATION=false`). The first account on an empty system becomes Admin; after that, admins create accounts. A role sent by the client is always ignored. | +| Real-time (Socket.IO) | Connections must be authenticated (the `access_token` cookie). Joining a project or deployment room requires the same access as the HTTP API. Events carry minimal data, never secrets, and are no longer broadcast to everyone. | +| Two-factor login | The 2FA step is bound to the password step through a short-lived, single-use, httpOnly cookie. Per-account lockout after repeated wrong codes. New, much stronger backup codes. | +| Pipeline conditions | `RunIf` is evaluated by a safe parser instead of being executed as code. | +| Command execution | git, rsync, chown and ssh-keygen no longer go through a shell. Branches, commits, repository URLs and rsync options are validated strictly. | +| Access control | Every project and deployment route checks project membership. Viewers only see projects they are members of. | +| Secrets in the API | Webhook secrets, SSH private keys, secret variable values, notification tokens and password hashes are never returned. The API reports only whether a value is set (`HasValue: true`). | +| Startup | In production the server refuses to start with missing, short or placeholder secrets. Real environment variables now take precedence over `.env`. | +| Install / build | A fresh install on an empty database now boots (baseline migration `000_initial_schema`). `npm run build` no longer deletes `dist/` before compiling. Vulnerable dependencies were updated. | +| Legacy webhooks | `/webhook/github/:project` (v2.1 URL) works again; it was being rejected by CSRF protection. | + +--- + +## 2. Breaking changes + +Read these before upgrading. + +1. **Registration is closed.** `POST /api/auth/register` returns `403` once + any user exists. To allow self-service sign-up again, set + `ALLOW_REGISTRATION=true`; self-registered users are always **Viewer**. + On a fresh install with no `DEFAULT_ADMIN_*` variables, the first account + registered becomes Admin, so create it right after installation. The + bundled UI has no sign-up page; use `DEFAULT_ADMIN_*`, or the API: + ```bash + BASE=https://deploy.example.com + curl -s -c jar -b jar -A dc-setup "$BASE/api/auth/registration-status" # SetupRequired: true + TOKEN=$(awk '$6=="XSRF-TOKEN"{print $7}' jar) + curl -s -c jar -b jar -A dc-setup -H 'Content-Type: application/json' \ + -H "X-XSRF-TOKEN: $TOKEN" \ + -d '{"Username":"admin","Email":"you@example.com","Password":""}' \ + "$BASE/api/auth/register" + rm jar + ``` + (The custom `-A` user agent is needed because the request filter rejects + the default curl user agent.) +2. **Secrets are write-only.** + - The webhook secret is no longer displayed. To get one, **regenerate** + it: the new value is returned once (the rebuilt UI shows it once; with + the bundled UI, use the CLI in section 4.1). + - Secret values in project variables and legacy notification settings + come back as `********`. Saving a form that still contains `********` + keeps the stored value; type a new value to replace it. + - Variables named `BuildCommand`, `BuildCmd`, `BuildOutput`, + `TargetPath`, `Target`, `Pm2Name` and `Type` stay readable. Every other + legacy variable value is treated as secret. + - Repository URLs are shown without embedded credentials. + - API consumers that read `Project.WebhookSecret`, `SshKeyEncrypted` or + secret values must switch to the `Secrets` map (`{ "": { "HasValue": true } }`). +3. **Two-factor login flow.** + - `POST /api/auth/verify-2fa` takes `{ "Code": "..." }` and requires the + `mfa_pending` cookie set by `/api/auth/login` in the same browser + session. A `UserId` in the body is ignored. + - The pending step expires after 5 minutes and is burned after 5 wrong + codes; the user must enter the password again. + - 5 consecutive wrong codes lock the second factor for 15 minutes. + - **Backup codes issued before 3.0.1 no longer work.** Users with 2FA + must regenerate backup codes (Settings → Security). Their authenticator + app keeps working. The 2FA status response reports + `legacyBackupCodesInvalidated: true` for affected accounts. +4. **Viewers** only see projects they are members of (as documented in the + RBAC matrix). Add Viewers as project members where needed. +5. **Managers** can now manage SSH keys, deploy and cancel on any project, + matching the documented RBAC matrix (they were wrongly refused before). +6. **Stricter project validation** (a deploy of an invalid project fails + with a clear error; saving one returns `400`): + - Repository URL must be `https://…`, `ssh://…` or `user@host:path`. + `http://`, `git://`, `file://` and local paths are rejected. + - Branch names may only contain letters, digits and `. _ / + @ -`, and + must be valid git branch names. + - Commit hashes must be 7–40 lowercase hex characters. + - `RsyncOptions` may only contain allow-listed flags (for example + `-rv --delete --omit-dir-times --no-perms --no-owner --no-group`); + `-e`, `--rsh` and `--rsync-path` are refused. +7. **Production refuses insecure secrets.** With `NODE_ENV=production`, boot + fails if `JWT_SECRET`, `JWT_REFRESH_SECRET` or `ENCRYPTION_KEY` is + missing, shorter than 32 characters or a placeholder, if the two JWT + secrets are equal, or if `DB_PASSWORD` / `DEFAULT_ADMIN_PASSWORD` is a + shipped placeholder. +8. **Environment precedence.** Variables from the real environment + (systemd, Docker, PM2 `env_production`) now override `.env`. If you + relied on `.env` overriding them, move the value. +9. **Node.js 20 or newer** is required (nodemailer 10; Node 18 is EOL). +10. **Socket.IO clients** must authenticate. Browsers do this automatically + with the session cookie; custom clients pass a valid access token as + `auth: { token }`. +11. **Test-webhook endpoints** (`/api/webhooks/test/:project`) now require + login and access to the project. + +### Find projects that will fail validation + +```sql +SELECT Id, Name, RepoUrl, Branch +FROM Projects +WHERE RepoUrl NOT REGEXP '^(https://|ssh://|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:)' + OR Branch NOT REGEXP '^[A-Za-z0-9._/+@-]+$' + OR JSON_UNQUOTE(JSON_EXTRACT(Config, '$.RsyncOptions')) REGEXP '(^|[[:space:]])(-e|--rsh|--rsync-path)'; +``` + +--- + +## 3. Upgrade steps + +1. **Back up** the database **and `ENCRYPTION_KEY`**. Without the key, every + encrypted value (SSH keys, environment variables, notification provider + credentials, 2FA secrets) is unrecoverable. +2. Install Node.js 20 or 22. +3. Make sure production secrets are strong (section 4.2 shows how to + generate them). +4. Deploy the new version: + ```bash + git pull + npm ci + npm run build # dist/ is replaced only if compilation succeeds + pm2 reload ecosystem.config.js --env production + ``` + Migrations `000_initial_schema` (no-op on existing databases) and + `022_create_mfa_challenges_and_2fa_lockout` run automatically when + `DB_AUTO_MIGRATE=true`, or run `npm run migrate`. +5. Check the startup log for `Insecure configuration` warnings or a + `Refusing to start` error. +6. Complete **all** rotation steps in section 4. + +--- + +## 4. Mandatory secret rotation + +Before 3.0.1, project data including webhook secrets and encrypted SSH keys +could be received by anyone able to reach the server. Treat all of the +following as compromised. + +### 4.1 Webhook secrets (every project) + +```bash +# Rotate all projects. New secrets go ONLY to a 0600 file, never to the +# terminal or the logs. The file is never overwritten. +npm run rotate:webhook-secrets -- --all --out ./webhook-secrets.json + +# One project, by id or name +npm run rotate:webhook-secrets -- --only my-app --out ./my-app-secret.json + +# Preview without changing anything +npm run rotate:webhook-secrets -- --all --dry-run +``` + +`npm run rotate:webhook-secrets` uses `ts-node` (a dev dependency). On a host +installed with `npm ci --omit=dev`, run the compiled script instead: + +```bash +node -r ./tsconfig-paths-bootstrap.js dist/scripts/rotate-webhook-secrets.js --all --out ./webhook-secrets.json +``` + +Then, for each project, open **GitHub → Settings → Webhooks** for the +repository, paste the new secret from the file, save, and use "Redeliver" +on a recent delivery to confirm it is accepted. Delete the file when done. +Each rotation is recorded in the audit log as `webhook_secret_rotated`. + +You can also rotate one project from the UI or API +(`POST /api/projects/:id/regenerate-webhook`), which returns the new secret once. + +### 4.2 JWT secrets + +Rotating these signs everyone out, which is the point: any session or token +minted with an old or default secret becomes invalid. + +```bash +openssl rand -hex 64 # JWT_SECRET +openssl rand -hex 64 # JWT_REFRESH_SECRET (must differ) +``` + +Set both in the environment (or `.env`) and restart. If you ever ran with the +built-in defaults, also review the user list for accounts you do not +recognise: + +```sql +SELECT Id, Username, Email, Role, CreatedAt, LastLogin FROM Users ORDER BY CreatedAt; +``` + +### 4.3 SSH deploy keys (every project that uses one) + +For each project with an SSH key: + +1. Regenerate the key: project page → SSH Key → Regenerate, or + `PUT /api/projects/:id/ssh-key`. +2. Add the new public key as a deploy key on the repository (read-only). +3. **Delete the old deploy key** from the repository. Regenerating on the + server does not revoke the old key on GitHub. + +### 4.4 Other values that may have been exposed + +- **Legacy project variables** (`Config.Variables`) and **legacy + notification settings** (Discord/Slack webhook URLs, Telegram bot tokens, + SMTP passwords): rotate them at the provider, then enter the new values. +- **If `ENCRYPTION_KEY` was ever a default or placeholder value**, every + encrypted value must be considered readable. 3.0.1 does not include a + re-encryption tool (planned for 3.1). Set a new key (`openssl rand -hex 32`), + then re-create everything that was encrypted with the old one: + regenerate SSH keys (4.3), re-enter environment variable values and + notification provider credentials, and have users with 2FA disable and + re-enrol it. Values encrypted with the old key can no longer be decrypted + after the change. + +### 4.5 Review activity + +Look for deployments you did not expect, especially manual ones and ones +from unusual branches: + +```sql +SELECT Id, ProjectId, TriggerType, Branch, CommitHash, Author, CreatedAt +FROM Deployments +ORDER BY CreatedAt DESC +LIMIT 200; +``` + +--- + +## 5. Known gaps + +- **Client v3.0 source is not in the client repository (UI-H0).** The UI the + server serves from `public/` is a v3.0 build whose source is not committed; + the client repository holds older (v2.1-based) source. The 3.0.1 client + changes (socket reconnect, cookie-bound 2FA, write-only secrets) are made + in that source and cannot be applied to the bundled UI. The server keeps + the bundled UI working: 2FA login (it still receives the user id it + expects, which the server no longer trusts), authenticated real-time + updates and project editing were verified with the bundled UI. With the bundled UI, the webhook secret field shows "Not + generated" even when a secret is set, and a regenerated secret is not + displayed; use the CLI in 4.1. Recovering and committing the v3.0 client + source (and building it in CI) is required before the next client + release. +- Pipelines still run on the host as the server user. Isolated runners are + planned for v4.0. Only give Developer access to people you trust with the + server account. +- Sessions are still stateless JWTs without server-side revocation; + rotating the JWT secrets (4.2) is the way to revoke all of them. diff --git a/package-lock.json b/package-lock.json index 8a10c24..34ad572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deploy-center-server", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deploy-center-server", - "version": "3.0.0", + "version": "3.0.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@bull-board/api": "^5.23.0", From aaeabc2084f80989127d97592d56c3db460cb1e1 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:03:49 +0000 Subject: [PATCH 15/22] ci: install dev dependencies in the fresh-install job The job sets NODE_ENV=production for the server, which made npm ci skip devDependencies, so the TypeScript build had no compiler. --- .github/workflows/build-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 94fa2b1..96f6f74 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -156,7 +156,7 @@ jobs: cache: 'npm' - name: 📦 Install dependencies - run: npm ci + run: npm ci --include=dev - name: 🏗️ Build run: npm run build From 41032d1d213e600a80aec90d262255c0db337bca Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:08:17 +0000 Subject: [PATCH 16/22] fix(security): validate the 2FA token in the service and ignore unsafe config keys Move the shape check of the mfa_pending cookie into CompleteTwoFactorLogin, next to the hashed lookup that decides it, so the controller holds no security condition of its own. When merging masked secrets, drop __proto__, constructor and prototype keys and only restore values the stored config owns. Exclude __tests__ from CodeQL. --- .github/workflows/security.yml | 1 + __tests__/unit/Utils/DtoMapper.test.ts | 27 ++++++++++++++++++++++++++ src/Controllers/AuthController.ts | 7 +------ src/Services/AuthService.ts | 5 ++++- src/Utils/DtoMapper.ts | 12 +++++++++++- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 __tests__/unit/Utils/DtoMapper.test.ts diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 77e0e96..0da6ff5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -37,6 +37,7 @@ jobs: paths-ignore: - public - dist + - __tests__ - node_modules - '**/*.min.js' - '**/*.bundle.js' diff --git a/__tests__/unit/Utils/DtoMapper.test.ts b/__tests__/unit/Utils/DtoMapper.test.ts new file mode 100644 index 0000000..aff4fe5 --- /dev/null +++ b/__tests__/unit/Utils/DtoMapper.test.ts @@ -0,0 +1,27 @@ +import { MergeMaskedProjectConfig } from '@Utils/DtoMapper'; + +describe('MergeMaskedProjectConfig', () => { + it('drops prototype-related keys instead of writing them', () => { + const incoming = JSON.parse( + '{"Variables":{"__proto__":"********","constructor":"x","prototype":"y","API_KEY":"********"}}' + ); + const stored = { Variables: { API_KEY: 'secret-value' } }; + + const merged = MergeMaskedProjectConfig(incoming, stored) as { + Variables: Record; + }; + + expect(Object.keys(merged.Variables)).toEqual(['API_KEY']); + expect(merged.Variables.API_KEY).toBe('secret-value'); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(merged.Variables)).toBe(Object.prototype); + }); + + it('only restores a masked value the stored config owns', () => { + const incoming = { Variables: { toString: '********' } }; + const merged = MergeMaskedProjectConfig(incoming, { Variables: {} }) as { + Variables: Record; + }; + expect(Object.prototype.hasOwnProperty.call(merged.Variables, 'toString')).toBe(false); + }); +}); diff --git a/src/Controllers/AuthController.ts b/src/Controllers/AuthController.ts index 9f3cac4..c071fcf 100644 --- a/src/Controllers/AuthController.ts +++ b/src/Controllers/AuthController.ts @@ -266,12 +266,7 @@ export class AuthController { public VerifyTwoFactor = async (req: Request, res: Response): Promise => { try { const { Code } = req.body as { Code: string }; - const mfaToken = (req.cookies as Record | undefined)?.[MFA_COOKIE_NAME]; - - if (typeof mfaToken !== 'string' || mfaToken.length === 0 || mfaToken.length > 256) { - ResponseHelper.Unauthorized(res, 'Two-factor session expired. Please sign in again.'); - return; - } + const mfaToken: unknown = (req.cookies as Record | undefined)?.[MFA_COOKIE_NAME]; // Extract device info const deviceInfo = this.ExtractDeviceInfo(req); diff --git a/src/Services/AuthService.ts b/src/Services/AuthService.ts index 3eb2939..dfd46c1 100644 --- a/src/Services/AuthService.ts +++ b/src/Services/AuthService.ts @@ -289,7 +289,7 @@ export class AuthService { * MFA_CHALLENGE_MAX_ATTEMPTS is reached; a correct one keeps it consumed. */ public async CompleteTwoFactorLogin( - mfaToken: string, + mfaToken: unknown, code: string, deviceInfo?: IDeviceInfo ): Promise<{ @@ -299,6 +299,9 @@ export class AuthService { }> { let userId: number | undefined; try { + if (typeof mfaToken !== 'string' || mfaToken.length === 0 || mfaToken.length > 256) { + throw new MfaChallengeError(); + } const challenge = await MfaChallenge.findOne({ where: { TokenHash: HashMfaToken(mfaToken) }, }); diff --git a/src/Utils/DtoMapper.ts b/src/Utils/DtoMapper.ts index 24720bb..fdd330f 100644 --- a/src/Utils/DtoMapper.ts +++ b/src/Utils/DtoMapper.ts @@ -141,6 +141,12 @@ function IsRecord(value: unknown): value is TRow { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + +function HasOwn(record: TRow, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + function HasSecretValue(value: unknown): boolean { return value !== undefined && value !== null && value !== ''; } @@ -213,8 +219,12 @@ export function MergeMaskedProjectConfig(incoming: T, stored: unknown): T { const before = previous[bag]; if (!IsRecord(vars)) continue; for (const key of Object.keys(vars)) { + if (UNSAFE_KEYS.has(key)) { + delete vars[key]; + continue; + } if (vars[key] === SECRET_MASK) { - if (IsRecord(before) && before[key] !== undefined) { + if (IsRecord(before) && HasOwn(before, key) && before[key] !== undefined) { vars[key] = before[key]; } else { delete vars[key]; From ba6dfbbf9e7ab9f799acc34ab2fa179d5272b1d4 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:11:17 +0000 Subject: [PATCH 17/22] fix(cli): save rotated webhook secrets before applying them The rotation script changed the database first and wrote the secrets file afterwards, so a failed write left live secrets nobody had. It now plans the new secrets, writes and fsyncs the 0600 file, then applies every project in one transaction, and removes the file if that transaction fails. --- .../integration/WebhookSecretRotation.test.ts | 21 ++++++ src/Services/WebhookSecretRotationService.ts | 72 +++++++++++++------ src/scripts/rotate-webhook-secrets.ts | 22 +++++- 3 files changed, 90 insertions(+), 25 deletions(-) diff --git a/__tests__/integration/WebhookSecretRotation.test.ts b/__tests__/integration/WebhookSecretRotation.test.ts index 88763ac..6997293 100644 --- a/__tests__/integration/WebhookSecretRotation.test.ts +++ b/__tests__/integration/WebhookSecretRotation.test.ts @@ -72,6 +72,27 @@ describe('WebhookSecretRotationService', () => { expect(await secretOf(a.Id)).toBe(before); }); + it('plans new secrets without changing any project', async () => { + if (!dbUp) return; + const a = await makeProject(); + const before = await secretOf(a.Id); + const plan = await WebhookSecretRotationService.Plan({ Project: String(a.Id) }); + expect(plan[0]!.WebhookSecret).toMatch(/^[0-9a-f]{64}$/); + expect(await secretOf(a.Id)).toBe(before); + await WebhookSecretRotationService.Apply(plan); + expect(await secretOf(a.Id)).toBe(plan[0]!.WebhookSecret); + }); + + it('applies all planned secrets or none of them', async () => { + if (!dbUp) return; + const a = await makeProject(); + const before = await secretOf(a.Id); + const plan = await WebhookSecretRotationService.Plan({ Project: String(a.Id) }); + const broken = [...plan, { ...plan[0]!, ProjectId: 2147483000 }]; + await expect(WebhookSecretRotationService.Apply(broken)).rejects.toThrow('no longer exists'); + expect(await secretOf(a.Id)).toBe(before); + }); + it('requires an explicit target and a match', async () => { if (!dbUp) return; await expect(WebhookSecretRotationService.Rotate({})).rejects.toThrow(); diff --git a/src/Services/WebhookSecretRotationService.ts b/src/Services/WebhookSecretRotationService.ts index 0926a7c..8724722 100644 --- a/src/Services/WebhookSecretRotationService.ts +++ b/src/Services/WebhookSecretRotationService.ts @@ -33,7 +33,11 @@ export class WebhookSecretRotationService { return crypto.randomBytes(32).toString('hex'); } - public static async Rotate(target: IRotationTarget): Promise { + /** + * Choose the projects and generate their new secrets without touching the + * database, so the caller can store the secrets safely before applying them. + */ + public static async Plan(target: IRotationTarget): Promise { if (!target.All && !target.Project) { throw new Error('Choose --all or --only '); } @@ -51,30 +55,52 @@ export class WebhookSecretRotationService { throw new Error('No matching projects'); } - const rotated: IRotatedSecret[] = []; - for (const project of projects) { - const secret = target.DryRun ? null : WebhookSecretRotationService.NewSecret(); - if (secret) { - await project.update({ WebhookSecret: secret }); - await AuditLog.create({ - Action: WEBHOOK_SECRET_ROTATED, - ResourceType: 'project', - ResourceId: project.Id, - Details: { source: 'cli', projectName: project.Name }, - }); - } - rotated.push({ - ProjectId: project.Id, - Name: project.Name, - WebhookPath: `/api/webhooks/github/${project.Name}`, - WebhookSecret: secret, - }); - } + return projects.map((project) => ({ + ProjectId: project.Id, + Name: project.Name, + WebhookPath: `/api/webhooks/github/${project.Name}`, + WebhookSecret: target.DryRun ? null : WebhookSecretRotationService.NewSecret(), + })); + } - Logger.Info(`Webhook secrets ${target.DryRun ? 'selected (dry run)' : 'rotated'}`, { - projectIds: rotated.map((r) => r.ProjectId), + /** Store planned secrets in one transaction: all projects rotate or none do. */ + public static async Apply(plan: IRotatedSecret[]): Promise { + const sequelize = Project.sequelize; + if (!sequelize) { + throw new Error('Database is not initialized'); + } + await sequelize.transaction(async (transaction) => { + for (const entry of plan) { + if (!entry.WebhookSecret) continue; + const [updated] = await Project.update( + { WebhookSecret: entry.WebhookSecret }, + { where: { Id: entry.ProjectId }, transaction } + ); + if (updated !== 1) { + throw new Error(`Project #${entry.ProjectId} no longer exists`); + } + await AuditLog.create( + { + Action: WEBHOOK_SECRET_ROTATED, + ResourceType: 'project', + ResourceId: entry.ProjectId, + Details: { source: 'cli', projectName: entry.Name }, + }, + { transaction } + ); + } }); - return rotated; + Logger.Info('Webhook secrets rotated', { projectIds: plan.map((r) => r.ProjectId) }); + } + + public static async Rotate(target: IRotationTarget): Promise { + const plan = await WebhookSecretRotationService.Plan(target); + if (target.DryRun) { + Logger.Info('Webhook secrets selected (dry run)', { projectIds: plan.map((r) => r.ProjectId) }); + return plan; + } + await WebhookSecretRotationService.Apply(plan); + return plan; } } diff --git a/src/scripts/rotate-webhook-secrets.ts b/src/scripts/rotate-webhook-secrets.ts index d8bb060..a2e2f9d 100644 --- a/src/scripts/rotate-webhook-secrets.ts +++ b/src/scripts/rotate-webhook-secrets.ts @@ -47,6 +47,16 @@ function ParseArgs(argv: string[]): IArgs { return args; } +function WriteSecretsFile(file: string, data: unknown): void { + const fd = fs.openSync(file, 'wx', 0o600); + try { + fs.writeSync(fd, `${JSON.stringify(data, null, 2)}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } +} + function Write(line: string): void { process.stdout.write(`${line}\n`); } @@ -60,14 +70,22 @@ async function Main(): Promise { DatabaseConnection.GetInstance(); await DatabaseConnection.TestConnection(); - const rotated = await WebhookSecretRotationService.Rotate({ + const rotated = await WebhookSecretRotationService.Plan({ All: args.All, Project: args.Project, DryRun: args.DryRun, }); if (!args.DryRun) { - fs.writeFileSync(args.Out, `${JSON.stringify(rotated, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + // Save the new secrets durably before any project changes, so a failed + // write can never leave live secrets that nobody has. + WriteSecretsFile(args.Out, rotated); + try { + await WebhookSecretRotationService.Apply(rotated); + } catch (error) { + fs.rmSync(args.Out, { force: true }); + throw new Error(`No secret was changed: ${(error as Error).message}`); + } } Write(args.DryRun ? 'Dry run, would rotate:' : 'Rotated webhook secrets for:'); From 81e33ff6f836408843f1099294a07c31c4c7119a Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:16:21 +0000 Subject: [PATCH 18/22] fix(socket): re-authorize open sockets when access changes (SEC-C2) Rooms were resolved once at connection time, so a socket kept receiving a project's events after the user lost access. Every 30 seconds, and right after a role change, a deactivation or a removed project membership, each socket now re-verifies its token (expiry and current JWT secret), reloads the user, leaves rooms it may no longer see and joins newly granted ones. A disabled account or an invalid token disconnects the socket. The 2FA service also stops branching on the cookie value: a missing or malformed token simply matches no stored challenge. --- __tests__/integration/SocketAuth.test.ts | 82 ++++++++++++++++++++++ src/Controllers/ProjectController.ts | 2 + src/Services/AuthService.ts | 8 +-- src/Services/SocketService.ts | 88 +++++++++++++++++++++++- src/Services/UserProfileService.ts | 3 + 5 files changed, 178 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/SocketAuth.test.ts b/__tests__/integration/SocketAuth.test.ts index 153f8f8..e1d9059 100644 --- a/__tests__/integration/SocketAuth.test.ts +++ b/__tests__/integration/SocketAuth.test.ts @@ -11,6 +11,7 @@ import { AddressInfo } from 'net'; import jwt from 'jsonwebtoken'; import { io as ioClient, Socket as ClientSocket } from 'socket.io-client'; import AppConfig from '@Config/AppConfig'; +import { EUserRole } from '@Types/ICommon'; import SocketService from '@Services/SocketService'; import { Deployment, Project, ProjectMember } from '@Models/index'; import type { User } from '@Models/User'; @@ -222,6 +223,87 @@ describe('Socket.IO auth — SEC-C2', () => { expect(logs).toHaveLength(0); }); + it('drops project rooms after the membership is removed', async () => { + if (!dbUp) return; + const dev = await makeUser({ Role: 'Developer' }); + const project = await makeProject(); + await ProjectMember.create({ + ProjectId: project.Id, + UserId: dev.Id, + Role: 'member', + AddedBy: dev.Id, + }); + const created = await makeDeployment({ ProjectId: project.Id }); + const deployment = await Deployment.findByPk(created.Id, { + include: [{ model: Project, as: 'Project' }], + }); + + const socket = connect({ cookie: cookieFor(dev) }); + await connected(socket); + expect(await join(socket, 'join:deployment', created.Id)).toEqual({ Ok: true }); + + const updates = collect(socket, 'deployment:updated'); + const logs = collect(socket, 'deployment:log'); + await ProjectMember.destroy({ where: { ProjectId: project.Id, UserId: dev.Id } }); + await SocketService.GetInstance().ReconcileAll(); + + SocketService.GetInstance().EmitDeploymentUpdate(deployment as Deployment); + SocketService.GetInstance().EmitDeploymentLog(created.Id, 'build output'); + await wait(200); + expect(socket.connected).toBe(true); + expect(updates).toHaveLength(0); + expect(logs).toHaveLength(0); + }); + + it('leaves the all-projects room after a role downgrade', async () => { + if (!dbUp) return; + const admin = await makeUser({ Role: 'Admin' }); + const created = await makeDeployment(); + const deployment = await Deployment.findByPk(created.Id, { + include: [{ model: Project, as: 'Project' }], + }); + const socket = connect({ cookie: cookieFor(admin) }); + await connected(socket); + + const updates = collect(socket, 'deployment:updated'); + await admin.update({ Role: EUserRole.Viewer }); + await SocketService.GetInstance().ReconcileUser(admin.Id); + + SocketService.GetInstance().EmitDeploymentUpdate(deployment as Deployment); + await wait(200); + expect(updates).toHaveLength(0); + }); + + it('disconnects the sockets of a disabled account', async () => { + if (!dbUp) return; + const dev = await makeUser({ Role: 'Developer' }); + const socket = connect({ cookie: cookieFor(dev) }); + await connected(socket); + const closed = new Promise((resolve) => socket.once('disconnect', () => resolve())); + + await dev.update({ IsActive: false }); + await SocketService.GetInstance().ReconcileAll(); + await closed; + expect(socket.connected).toBe(false); + }); + + it('disconnects a socket whose token no longer verifies', async () => { + if (!dbUp) return; + const dev = await makeUser({ Role: 'Developer' }); + const expiring = jwt.sign({ UserId: dev.Id }, AppConfig.Jwt.Secret, { + algorithm: 'HS256', + expiresIn: 1, + }); + const socket = connect({ cookie: `access_token=${expiring}` }); + await connected(socket); + const closed = new Promise((resolve) => socket.once('disconnect', () => resolve())); + + await wait(1500); + await SocketService.GetInstance().ReconcileAll(); + await closed; + expect(socket.connected).toBe(false); + }); + it('keeps the JWT secret configured for tests', () => { expect(AppConfig.Jwt.Secret.length).toBeGreaterThan(32); }); diff --git a/src/Controllers/ProjectController.ts b/src/Controllers/ProjectController.ts index 15e7953..3766b2d 100644 --- a/src/Controllers/ProjectController.ts +++ b/src/Controllers/ProjectController.ts @@ -9,6 +9,7 @@ import ProjectService from '@Services/ProjectService'; import DeploymentService from '@Services/DeploymentService'; import ResponseHelper from '@Utils/ResponseHelper'; import Logger from '@Utils/Logger'; +import SocketService from '@Services/SocketService'; import { EUserRole } from '@Types/ICommon'; import AccessControlService, { IAccessSubject } from '@Services/AccessControlService'; import { ToDeploymentResponseDto, ToProjectDto } from '@Utils/DtoMapper'; @@ -671,6 +672,7 @@ export class ProjectController { } await this.ProjectService.RemoveProjectMember(projectId, userId, req); + void SocketService.GetInstance().ReconcileUser(userId); ResponseHelper.Success(res, 'Member removed from project successfully'); } catch (error) { diff --git a/src/Services/AuthService.ts b/src/Services/AuthService.ts index dfd46c1..1913bb9 100644 --- a/src/Services/AuthService.ts +++ b/src/Services/AuthService.ts @@ -299,11 +299,11 @@ export class AuthService { }> { let userId: number | undefined; try { - if (typeof mfaToken !== 'string' || mfaToken.length === 0 || mfaToken.length > 256) { - throw new MfaChallengeError(); - } + // A missing or malformed cookie hashes to a value no challenge has, so + // the lookup below is the only decision. + const token = typeof mfaToken === 'string' ? mfaToken : ''; const challenge = await MfaChallenge.findOne({ - where: { TokenHash: HashMfaToken(mfaToken) }, + where: { TokenHash: HashMfaToken(token) }, }); if (!challenge || challenge.ConsumedAt || new Date(challenge.ExpiresAt) <= new Date()) { throw new MfaChallengeError(); diff --git a/src/Services/SocketService.ts b/src/Services/SocketService.ts index 0606d98..368aea4 100644 --- a/src/Services/SocketService.ts +++ b/src/Services/SocketService.ts @@ -12,6 +12,12 @@ import { ToDeploymentDto } from '@Utils/DtoMapper'; export const STAFF_ROOM = 'scope:all-projects'; /** Room every authenticated socket joins (non-sensitive system events). */ export const AUTHENTICATED_ROOM = 'scope:authenticated'; +/** + * How often open sockets are re-authorized. A role change, a removed project + * membership, a disabled account, an expired token or a rotated JWT secret + * takes effect on live sockets within this interval. + */ +export const SOCKET_RECONCILE_INTERVAL_MS = 30_000; export interface ISocketUser extends IAccessSubject { Username: string; @@ -44,6 +50,7 @@ export function ReadCookie(header: string | undefined, name: string): string | n export class SocketService { private static Instance: SocketService; private IO?: SocketIOServer; + private ReconcileTimer?: NodeJS.Timeout; // Per-deployment log sink: redacts + persists the SAME line that is streamed // to the socket, so the stored log matches the live view character-for-character. @@ -102,6 +109,11 @@ export class SocketService { this.HandleConnection(socket); }); + this.ReconcileTimer = setInterval(() => { + void this.ReconcileAll(); + }, SOCKET_RECONCILE_INTERVAL_MS); + this.ReconcileTimer.unref(); + Logger.Info('Socket.IO initialized successfully'); } @@ -109,6 +121,10 @@ export class SocketService { * Close the Socket.IO server (graceful shutdown and test teardown). */ public async Shutdown(): Promise { + if (this.ReconcileTimer) { + clearInterval(this.ReconcileTimer); + this.ReconcileTimer = undefined; + } if (!this.IO) return; const io = this.IO; this.IO = undefined; @@ -139,7 +155,77 @@ export class SocketService { if (userId === null) { throw new Error('Malformed token payload'); } - return this.LoadActiveUser(userId); + const user = await this.LoadActiveUser(userId); + // Kept in memory only, so the reconciler can re-verify expiry and secret. + socket.data.token = token; + return user; + } + + /** + * Re-authorize every open socket (see SOCKET_RECONCILE_INTERVAL_MS). + */ + public async ReconcileAll(): Promise { + if (!this.IO) return; + const sockets = [...this.IO.of('/').sockets.values()]; + for (const socket of sockets) { + await this.ReconcileSocket(socket); + } + } + + /** + * Re-authorize the open sockets of one user right away, for callers that + * change that user's access. + */ + public async ReconcileUser(userId: number): Promise { + if (!this.IO) return; + const sockets = [...this.IO.of('/').sockets.values()].filter( + (socket) => (socket.data.user as ISocketUser | undefined)?.UserId === userId + ); + for (const socket of sockets) { + await this.ReconcileSocket(socket); + } + } + + /** + * Leave every room the user may no longer see, join any room newly granted, + * and disconnect when the token or the account is no longer valid. + */ + private async ReconcileSocket(socket: Socket): Promise { + try { + const user = socket.data.user as ISocketUser | undefined; + const token = socket.data.token as string | undefined; + if (!user || !token) { + throw new Error('Missing socket session'); + } + jwt.verify(token, AppConfig.Jwt.Secret, { algorithms: ['HS256'] }); + const current = await this.LoadActiveUser(user.UserId); + socket.data.user = current; + + const granted = new Set(await this.ResolveInitialRooms(current)); + for (const room of [...socket.rooms]) { + if (room === socket.id || granted.has(room)) continue; + const separator = room.indexOf(':'); + const kind = room.slice(0, separator); + const id = AccessControlService.ParseId(room.slice(separator + 1)); + let keep = false; + if (id !== null && kind === 'project') { + keep = await AccessControlService.CanViewProject(current, id); + } else if (id !== null && kind === 'deployment') { + keep = await AccessControlService.CanViewDeployment(current, id); + } + if (!keep) { + await socket.leave(room); + Logger.Info('Socket left a room after re-authorization', { socketId: socket.id, room }); + } + } + await socket.join([...granted]); + } catch (error) { + Logger.Info('Socket disconnected after re-authorization', { + socketId: socket.id, + reason: (error as Error).message, + }); + socket.disconnect(true); + } } private async LoadActiveUser(userId: number): Promise { diff --git a/src/Services/UserProfileService.ts b/src/Services/UserProfileService.ts index c5c60d2..fae9479 100644 --- a/src/Services/UserProfileService.ts +++ b/src/Services/UserProfileService.ts @@ -6,6 +6,7 @@ import { User, UserSettings } from '@Models/index'; import AuthService from './AuthService'; import Logger from '@Utils/Logger'; +import SocketService from '@Services/SocketService'; import PasswordHelper from '@Utils/PasswordHelper'; import { EAccountStatus, EUserRole } from '@Types/ICommon'; import { Op } from 'sequelize'; @@ -343,6 +344,7 @@ export class UserProfileService { oldRole, newRole: role, }); + void SocketService.GetInstance().ReconcileUser(userId); return user; } catch (error) { @@ -409,6 +411,7 @@ export class UserProfileService { await user.save(); Logger.Info('User deactivated', { userId }); + void SocketService.GetInstance().ReconcileUser(userId); return user; } catch (error) { From d53f312848bc08ec0d629c239e771aaf9fd06600 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:17:41 +0000 Subject: [PATCH 19/22] fix(cli): keep the secrets file when a failed rotation may have committed A transaction can commit even when its acknowledgement is lost. On an apply error the script now reads back the stored secrets: if all match, it reports success; if none match, it removes the file; otherwise it keeps the file and says how to check. --- .../integration/WebhookSecretRotation.test.ts | 9 ++++++ src/Services/WebhookSecretRotationService.ts | 12 +++++++ src/scripts/rotate-webhook-secrets.ts | 31 +++++++++++++++++-- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/__tests__/integration/WebhookSecretRotation.test.ts b/__tests__/integration/WebhookSecretRotation.test.ts index 6997293..0dee40b 100644 --- a/__tests__/integration/WebhookSecretRotation.test.ts +++ b/__tests__/integration/WebhookSecretRotation.test.ts @@ -93,6 +93,15 @@ describe('WebhookSecretRotationService', () => { expect(await secretOf(a.Id)).toBe(before); }); + it('counts how many planned secrets are stored', async () => { + if (!dbUp) return; + const a = await makeProject(); + const plan = await WebhookSecretRotationService.Plan({ Project: String(a.Id) }); + expect(await WebhookSecretRotationService.CountApplied(plan)).toBe(0); + await WebhookSecretRotationService.Apply(plan); + expect(await WebhookSecretRotationService.CountApplied(plan)).toBe(1); + }); + it('requires an explicit target and a match', async () => { if (!dbUp) return; await expect(WebhookSecretRotationService.Rotate({})).rejects.toThrow(); diff --git a/src/Services/WebhookSecretRotationService.ts b/src/Services/WebhookSecretRotationService.ts index 8724722..4db81c0 100644 --- a/src/Services/WebhookSecretRotationService.ts +++ b/src/Services/WebhookSecretRotationService.ts @@ -93,6 +93,18 @@ export class WebhookSecretRotationService { Logger.Info('Webhook secrets rotated', { projectIds: plan.map((r) => r.ProjectId) }); } + /** How many planned secrets the database currently holds. */ + public static async CountApplied(plan: IRotatedSecret[]): Promise { + const planned = plan.filter((entry) => entry.WebhookSecret); + if (planned.length === 0) return 0; + const projects = await Project.findAll({ + where: { Id: planned.map((entry) => entry.ProjectId) }, + attributes: ['Id', 'WebhookSecret'], + }); + const stored = new Map(projects.map((project) => [project.Id, project.get('WebhookSecret')])); + return planned.filter((entry) => stored.get(entry.ProjectId) === entry.WebhookSecret).length; + } + public static async Rotate(target: IRotationTarget): Promise { const plan = await WebhookSecretRotationService.Plan(target); if (target.DryRun) { diff --git a/src/scripts/rotate-webhook-secrets.ts b/src/scripts/rotate-webhook-secrets.ts index a2e2f9d..ac3d84f 100644 --- a/src/scripts/rotate-webhook-secrets.ts +++ b/src/scripts/rotate-webhook-secrets.ts @@ -17,7 +17,7 @@ import 'tsconfig-paths/register'; import fs from 'fs'; import path from 'path'; import DatabaseConnection from '@Database/DatabaseConnection'; -import WebhookSecretRotationService from '@Services/WebhookSecretRotationService'; +import WebhookSecretRotationService, { IRotatedSecret } from '@Services/WebhookSecretRotationService'; interface IArgs { All: boolean; @@ -57,6 +57,32 @@ function WriteSecretsFile(file: string, data: unknown): void { } } +/** + * The commit may have reached the database even if the reply did not. Check + * what is stored before deciding whether the secrets file is still needed. + */ +async function HandleApplyFailure(file: string, plan: IRotatedSecret[], error: Error): Promise { + const planned = plan.filter((entry) => entry.WebhookSecret).length; + let applied: number | null = null; + try { + applied = await WebhookSecretRotationService.CountApplied(plan); + } catch { + applied = null; + } + if (applied === planned) { + Write(`The database confirms the rotation despite the error (${error.message}).`); + return; + } + if (applied === 0) { + fs.rmSync(file, { force: true }); + throw new Error(`No secret was changed: ${error.message}`); + } + throw new Error( + `Could not confirm the rotation (${error.message}). Keep ${file}: it holds the new ` + + 'secrets. Re-run with --dry-run to list the projects, and compare before deleting it.' + ); +} + function Write(line: string): void { process.stdout.write(`${line}\n`); } @@ -83,8 +109,7 @@ async function Main(): Promise { try { await WebhookSecretRotationService.Apply(rotated); } catch (error) { - fs.rmSync(args.Out, { force: true }); - throw new Error(`No secret was changed: ${(error as Error).message}`); + await HandleApplyFailure(args.Out, rotated, error as Error); } } From c170baa3679e112231d2795514e2dd4e9eb558b5 Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:19:27 +0000 Subject: [PATCH 20/22] refactor(dto): rebuild merged variables instead of writing keys in place Builds the merged variable map with Object.fromEntries from the filtered entries, so no property name taken from the request is assigned on an existing object. --- src/Utils/DtoMapper.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/Utils/DtoMapper.ts b/src/Utils/DtoMapper.ts index fdd330f..ba308a1 100644 --- a/src/Utils/DtoMapper.ts +++ b/src/Utils/DtoMapper.ts @@ -218,19 +218,15 @@ export function MergeMaskedProjectConfig(incoming: T, stored: unknown): T { const vars = merged[bag]; const before = previous[bag]; if (!IsRecord(vars)) continue; - for (const key of Object.keys(vars)) { - if (UNSAFE_KEYS.has(key)) { - delete vars[key]; - continue; - } - if (vars[key] === SECRET_MASK) { - if (IsRecord(before) && HasOwn(before, key) && before[key] !== undefined) { - vars[key] = before[key]; - } else { - delete vars[key]; - } + const restored = Object.entries(vars).flatMap(([key, value]): Array<[string, unknown]> => { + if (UNSAFE_KEYS.has(key)) return []; + if (value !== SECRET_MASK) return [[key, value]]; + if (IsRecord(before) && HasOwn(before, key) && before[key] !== undefined) { + return [[key, before[key]]]; } - } + return []; + }); + merged[bag] = Object.fromEntries(restored); } const notifications = merged.Notifications; From 9c497bf025da43853f53139f0b1067b3b553547e Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:29:17 +0000 Subject: [PATCH 21/22] fix(access): decide project access by membership only (SEC-H4) Removing a project's creator from its members now removes their access. Migration 023 backfills an owner membership for every existing creator so no one loses access on upgrade. Signed-off-by: Sabry Dawood --- __tests__/integration/AccessControl.test.ts | 21 ++++++++++++ src/Database/MigrationRunner.ts | 7 ++++ .../023_backfill_creator_memberships.ts | 33 ++++++++++++++++++ src/Services/AccessControlService.ts | 34 ++++++++----------- 4 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 src/Migrations/023_backfill_creator_memberships.ts diff --git a/__tests__/integration/AccessControl.test.ts b/__tests__/integration/AccessControl.test.ts index 7875dcd..139e8eb 100644 --- a/__tests__/integration/AccessControl.test.ts +++ b/__tests__/integration/AccessControl.test.ts @@ -19,6 +19,8 @@ import QueueService from '@Services/QueueService'; import { Deployment, Project, ProjectMember } from '@Models/index'; import type { User } from '@Models/User'; import { SECRET_MASK } from '@Utils/DtoMapper'; +import AccessControlService from '@Services/AccessControlService'; +import { EUserRole } from '@Types/ICommon'; async function dbReachable(): Promise { try { @@ -233,4 +235,23 @@ describe('Access control + secret DTOs — SEC-H4 / SEC-H3', () => { expect(res.status).toBe(200); expect(JSON.stringify(res.body)).not.toContain('PasswordHash'); }); + + it('takes access away from a creator who was removed from the project', async () => { + if (!dbUp) return; + const creator = await makeUser({ Role: 'Developer' }); + const other = await makeUser({ Role: 'Developer' }); + const project = await makeProject({ CreatedBy: creator.Id }); + await ProjectMember.bulkCreate([ + { ProjectId: project.Id, UserId: creator.Id, Role: 'owner', AddedBy: creator.Id }, + { ProjectId: project.Id, UserId: other.Id, Role: 'owner', AddedBy: creator.Id }, + ]); + const subject = { UserId: creator.Id, Role: EUserRole.Developer }; + expect(await AccessControlService.CanViewProject(subject, project.Id)).toBe(true); + + await ProjectMember.destroy({ where: { ProjectId: project.Id, UserId: creator.Id } }); + expect(await AccessControlService.CanViewProject(subject, project.Id)).toBe(false); + expect(await AccessControlService.CanModifyProject(subject, project.Id)).toBe(false); + expect(await AccessControlService.GetAccessibleProjectIds(subject)).toEqual([]); + }); + }); diff --git a/src/Database/MigrationRunner.ts b/src/Database/MigrationRunner.ts index 22b40be..f464a8c 100644 --- a/src/Database/MigrationRunner.ts +++ b/src/Database/MigrationRunner.ts @@ -31,6 +31,7 @@ import * as Migration019 from '@Migrations/019_create_project_notification_subsc import * as Migration020 from '@Migrations/020_drop_user_notification_columns'; import * as Migration021 from '@Migrations/021_widen_deployments_text_columns'; import * as Migration022 from '@Migrations/022_create_mfa_challenges_and_2fa_lockout'; +import * as Migration023 from '@Migrations/023_backfill_creator_memberships'; import * as Migration999 from '@Migrations/999_migrate_pending_deployments'; interface IMigration { name: string; @@ -146,6 +147,12 @@ export class MigrationRunner { up: Migration022.up, down: Migration022.down, }, + { + // v3.0.1 SEC-H4 — explicit owner memberships, so access is membership-only. + name: '023_backfill_creator_memberships', + up: Migration023.up, + down: Migration023.down, + }, { // v3.0 F-001 — one-shot: re-enqueue v2.1 pending deployments into BullMQ. // Idempotent via QueueJobId IS NULL guard. Runs ONCE per env. diff --git a/src/Migrations/023_backfill_creator_memberships.ts b/src/Migrations/023_backfill_creator_memberships.ts new file mode 100644 index 0000000..89b9b92 --- /dev/null +++ b/src/Migrations/023_backfill_creator_memberships.ts @@ -0,0 +1,33 @@ +/** + * Migration 023 (v3.0.1 — SEC-H4): give every project creator an explicit + * owner membership. + * + * Access used to fall back to Projects.CreatedBy for projects created before + * ProjectMembers existed, so removing the creator from a project did not take + * their access away. After this backfill, access is decided by membership + * alone. Idempotent: only creators without a membership row get one. + */ + +import { QueryInterface, QueryTypes } from 'sequelize'; +import Logger from '@Utils/Logger'; + +export const up = async (queryInterface: QueryInterface): Promise => { + const [, inserted] = (await queryInterface.sequelize.query( + `INSERT INTO ProjectMembers (ProjectId, UserId, Role, AddedBy, AddedAt, CreatedAt, UpdatedAt) + SELECT p.Id, p.CreatedBy, 'owner', p.CreatedBy, NOW(), NOW(), NOW() + FROM Projects p + JOIN Users u ON u.Id = p.CreatedBy + WHERE p.CreatedBy IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ProjectMembers m WHERE m.ProjectId = p.Id AND m.UserId = p.CreatedBy + )`, + { type: QueryTypes.INSERT } + )) as [unknown, number]; + Logger.Info(`Migration 023: added ${inserted ?? 0} creator memberships`); +}; + +export const down = async (): Promise => { + // The backfilled rows cannot be told apart from memberships added later, so + // they stay. Access without them would fall back to nothing, not to more. + Logger.Info('Migration 023: down keeps the creator memberships'); +}; diff --git a/src/Services/AccessControlService.ts b/src/Services/AccessControlService.ts index 437a9a4..a627c74 100644 --- a/src/Services/AccessControlService.ts +++ b/src/Services/AccessControlService.ts @@ -7,11 +7,11 @@ * * RBAC (see CLAUDE.md): * - Admin / Manager: every project. - * - Developer: view + modify projects they are a member of (or created). + * - Developer: view + modify projects they are a member of. * - Viewer: view-only, projects they are a member of. */ -import { Deployment, Project, ProjectMember } from '@Models/index'; +import { Deployment, ProjectMember } from '@Models/index'; import { EUserRole } from '@Types/ICommon'; export interface IAccessSubject { @@ -39,24 +39,22 @@ export class AccessControlService { } /** - * Developer / Viewer relationship to a project: a ProjectMember row, or the - * legacy CreatedBy ownership for projects created before memberships existed. + * Developer / Viewer relationship to a project: a ProjectMember row only. + * Creators hold an owner membership (migration 023), so removing a member + * always removes their access. */ - public static async IsMemberOrCreator(userId: number, projectId: number): Promise { + public static async IsMember(userId: number, projectId: number): Promise { const membership = await ProjectMember.findOne({ where: { ProjectId: projectId, UserId: userId }, attributes: ['Id'], }); - if (membership) return true; - - const project = await Project.findByPk(projectId, { attributes: ['Id', 'CreatedBy'] }); - return Boolean(project && project.CreatedBy === userId); + return Boolean(membership); } public static async CanViewProject(subject: IAccessSubject, projectId: number): Promise { if (AccessControlService.IsStaff(subject)) return true; if (subject.Role !== EUserRole.Developer && subject.Role !== EUserRole.Viewer) return false; - return AccessControlService.IsMemberOrCreator(subject.UserId, projectId); + return AccessControlService.IsMember(subject.UserId, projectId); } public static async CanModifyProject( @@ -65,7 +63,7 @@ export class AccessControlService { ): Promise { if (AccessControlService.IsStaff(subject)) return true; if (subject.Role !== EUserRole.Developer) return false; - return AccessControlService.IsMemberOrCreator(subject.UserId, projectId); + return AccessControlService.IsMember(subject.UserId, projectId); } /** Resolve the project a deployment belongs to (null when it does not exist). */ @@ -101,15 +99,11 @@ export class AccessControlService { if (AccessControlService.IsStaff(subject)) return 'all'; if (subject.Role !== EUserRole.Developer && subject.Role !== EUserRole.Viewer) return []; - const [memberships, created] = await Promise.all([ - ProjectMember.findAll({ where: { UserId: subject.UserId }, attributes: ['ProjectId'] }), - Project.findAll({ where: { CreatedBy: subject.UserId }, attributes: ['Id'] }), - ]); - - const ids = new Set(); - memberships.forEach((m) => ids.add(m.get('ProjectId'))); - created.forEach((p) => ids.add(p.Id)); - return Array.from(ids); + const memberships = await ProjectMember.findAll({ + where: { UserId: subject.UserId }, + attributes: ['ProjectId'], + }); + return Array.from(new Set(memberships.map((m) => m.get('ProjectId')))); } } From f42a7cf478d8268f3c75ac9360462add30579f2e Mon Sep 17 00:00:00 2001 From: Sabry Dawood Date: Wed, 28 Oct 2026 00:38:30 +0000 Subject: [PATCH 22/22] fix(security): close the remaining access and credential leaks (SEC-H4) - Project delete requires an owner membership; drop the CreatedBy fallback. - Redact URL credentials from RunFile errors, cmd, stdout and stderr. - Reconcile a deleted user's sockets right away. - GET /api/users returns user DTOs (no TwoFactorSecret). - On an expired token, close only the socket transport so the client reconnects with its refreshed token instead of staying disconnected. Signed-off-by: Sabry Dawood --- __tests__/integration/AccessControl.test.ts | 4 ++++ __tests__/unit/Utils/SafeExec.test.ts | 19 +++++++++++++++++++ src/Controllers/UsersController.ts | 6 +++++- src/Middleware/ProjectAccessMiddleware.ts | 4 +--- src/Services/SocketService.ts | 6 ++++++ src/Services/UserProfileService.ts | 1 + src/Utils/SafeExec.ts | 16 +++++++++++++++- 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/__tests__/integration/AccessControl.test.ts b/__tests__/integration/AccessControl.test.ts index 139e8eb..28038aa 100644 --- a/__tests__/integration/AccessControl.test.ts +++ b/__tests__/integration/AccessControl.test.ts @@ -252,6 +252,10 @@ describe('Access control + secret DTOs — SEC-H4 / SEC-H3', () => { expect(await AccessControlService.CanViewProject(subject, project.Id)).toBe(false); expect(await AccessControlService.CanModifyProject(subject, project.Id)).toBe(false); expect(await AccessControlService.GetAccessibleProjectIds(subject)).toEqual([]); + + const del = await request(app).delete(`/api/projects/${project.Id}`).set(authHeader(creator)); + expect(del.status).toBe(403); + expect(await Project.findByPk(project.Id)).not.toBeNull(); }); }); diff --git a/__tests__/unit/Utils/SafeExec.test.ts b/__tests__/unit/Utils/SafeExec.test.ts index 4a3d0dd..2385d0b 100644 --- a/__tests__/unit/Utils/SafeExec.test.ts +++ b/__tests__/unit/Utils/SafeExec.test.ts @@ -15,6 +15,7 @@ import { CommandValidationError, ParseRsyncOptions, RedactUrlCredentials, + RedactUrlCredentialsInText, RunFile, } from '@Utils/SafeExec'; import { SanitizeKeyComment } from '@Utils/SshKeyGenerator'; @@ -177,12 +178,30 @@ describe('RunFile', () => { expect(JSON.parse(stdout)).toEqual([payload]); } ); + + it('strips URL credentials from a failed command error', async () => { + const url = 'https://deploy:s3cr3t-token@git.example.com/o/r.git'; + const error = await RunFile(process.execPath, [ + '-e', + 'process.stderr.write(process.argv[1]); process.exit(2)', + url, + ]).catch((e: Error & { cmd?: string; stderr?: string }) => e); + const failure = error as Error & { cmd?: string; stderr?: string }; + expect(failure.message).toContain('Command failed'); + for (const text of [failure.message, failure.cmd, failure.stderr]) { + expect(text).not.toContain('s3cr3t-token'); + } + expect(failure.stderr).toBe('https://git.example.com/o/r.git'); + }); }); describe('helpers', () => { it('redacts URL credentials for logs', () => { expect(RedactUrlCredentials('https://user:tok@github.com/o/r.git')).toBe('https://github.com/o/r.git'); expect(RedactUrlCredentials('git@github.com:o/r.git')).toBe('git@github.com:o/r.git'); + expect(RedactUrlCredentialsInText('fatal: https://u:p@h.io/x and ssh://k@h.io/y')).toBe( + 'fatal: https://h.io/x and ssh://h.io/y' + ); }); it('sanitizes ssh-keygen comments', () => { diff --git a/src/Controllers/UsersController.ts b/src/Controllers/UsersController.ts index c9f3558..e831f53 100644 --- a/src/Controllers/UsersController.ts +++ b/src/Controllers/UsersController.ts @@ -574,7 +574,11 @@ export class UsersController { isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, search: search as string, }); - ResponseHelper.Success(res, 'Users retrieved successfully', users); + ResponseHelper.Success( + res, + 'Users retrieved successfully', + users.map((u) => ToUserDto(u)) + ); } catch (error) { Logger.Error('Failed to get all users', error as Error); ResponseHelper.Error(res, (error as Error).message, undefined, 400); diff --git a/src/Middleware/ProjectAccessMiddleware.ts b/src/Middleware/ProjectAccessMiddleware.ts index 3405f1b..4a4622d 100644 --- a/src/Middleware/ProjectAccessMiddleware.ts +++ b/src/Middleware/ProjectAccessMiddleware.ts @@ -123,9 +123,7 @@ export class ProjectAccessMiddleware { const membership = await ProjectMember.findOne({ where: { ProjectId: projectId, UserId: subject.UserId }, }); - if (membership) return membership.get('Role') === 'owner'; - const project = await Project.findByPk(projectId, { attributes: ['Id', 'CreatedBy'] }); - return Boolean(project && project.CreatedBy === subject.UserId); + return Boolean(membership && membership.get('Role') === 'owner'); }, 'You can only delete projects you own. Members cannot delete projects.', 'delete' diff --git a/src/Services/SocketService.ts b/src/Services/SocketService.ts index 368aea4..ed34d57 100644 --- a/src/Services/SocketService.ts +++ b/src/Services/SocketService.ts @@ -224,6 +224,12 @@ export class SocketService { socketId: socket.id, reason: (error as Error).message, }); + if (error instanceof jwt.TokenExpiredError) { + // Drop only the transport: the client reconnects on its own and the + // handshake re-authenticates with the refreshed token. + socket.conn.close(); + return; + } socket.disconnect(true); } } diff --git a/src/Services/UserProfileService.ts b/src/Services/UserProfileService.ts index fae9479..4b7b823 100644 --- a/src/Services/UserProfileService.ts +++ b/src/Services/UserProfileService.ts @@ -367,6 +367,7 @@ export class UserProfileService { await user.destroy(); Logger.Info('User deleted', { userId, username: user.Username }); + void SocketService.GetInstance().ReconcileUser(userId); } catch (error) { Logger.Error('Failed to delete user', error as Error, { userId }); throw error; diff --git a/src/Utils/SafeExec.ts b/src/Utils/SafeExec.ts index 0bbfec3..5ecb627 100644 --- a/src/Utils/SafeExec.ts +++ b/src/Utils/SafeExec.ts @@ -53,7 +53,13 @@ export function RunFile(file: string, args: string[], options: IRunOptions = {}) }, (error, stdout, stderr) => { if (error) { - Object.assign(error, { stdout, stderr }); + // Node puts the full argv in message/cmd; a repo URL may carry a token. + error.message = RedactUrlCredentialsInText(error.message); + if (typeof error.cmd === 'string') error.cmd = RedactUrlCredentialsInText(error.cmd); + Object.assign(error, { + stdout: RedactUrlCredentialsInText(stdout), + stderr: RedactUrlCredentialsInText(stderr), + }); reject(error); return; } @@ -303,6 +309,13 @@ export function AssertSafePath(value: unknown, label: string = 'path'): string { return value; } +const URL_USERINFO = /(\b[A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/\s@]+@/g; + +/** Strip `user:token@` from every URL inside free text (error messages, stderr). */ +export function RedactUrlCredentialsInText(text: string): string { + return typeof text === 'string' ? text.replace(URL_USERINFO, '$1') : text; +} + /** * Strip credentials from a URL for logs ("https://token@host/x" → "https://host/x"). */ @@ -329,4 +342,5 @@ export default { AssertSafePath, IsValidCommitHash, RedactUrlCredentials, + RedactUrlCredentialsInText, };