diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b5933a..a9e92aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **security:** self-heal encryption key file permissions on startup — verifies `0600` and corrects if permissive; Docker entrypoint also enforces `chmod 600` before privilege drop +* **security:** increase minimum password length from 6 to 8 characters (NIST SP 800-63B) — applies to setup, password change, and user management; existing short passwords remain valid until changed +* **security:** remove sensitive data from console output — file paths, `.env` locations, stack names, and admin usernames no longer logged to stdout * **security:** use raw request bytes for webhook HMAC signature verification instead of re-serialized JSON — prevents signature mismatches from serialization differences * **security:** use NIST-recommended 12-byte IV for AES-256-GCM encryption (backward compatible with existing 16-byte IVs) * **security:** add 1-year default expiry to node proxy JWT tokens — previously issued without expiry diff --git a/backend/src/index.ts b/backend/src/index.ts index 73b44580..78de1fdf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -47,6 +47,7 @@ const _origEmitWarning = process.emitWarning.bind(process); _origEmitWarning(warning, ...args); }; +const MIN_PASSWORD_LENGTH = 8; const app = express(); const PORT = 3000; @@ -353,8 +354,8 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response) return; } - if (password.length < 6) { - res.status(400).json({ error: 'Password must be at least 6 characters' }); + if (password.length < MIN_PASSWORD_LENGTH) { + res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); return; } @@ -432,8 +433,8 @@ app.put('/api/auth/password', authMiddleware, async (req: Request, res: Response res.status(400).json({ error: 'Old password and new password are required' }); return; } - if (newPassword.length < 6) { - res.status(400).json({ error: 'New password must be at least 6 characters' }); + if (newPassword.length < MIN_PASSWORD_LENGTH) { + res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` }); return; } @@ -1797,8 +1798,8 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' }); return; } - if (typeof password !== 'string' || password.length < 6) { - res.status(400).json({ error: 'Password must be at least 6 characters' }); + if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { + res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); return; } const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; @@ -1888,8 +1889,8 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P } if (password !== undefined) { - if (typeof password !== 'string' || password.length < 6) { - res.status(400).json({ error: 'Password must be at least 6 characters' }); + if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { + res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` }); return; } updates.password_hash = await bcrypt.hash(password, 10); @@ -2491,13 +2492,11 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { } try { const { content } = req.body; - console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length }); if (typeof content !== 'string') { console.error('Content is not a string:', content); return res.status(400).json({ error: 'Content must be a string' }); } await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); - console.log('Stack saved successfully:', stackName); res.json({ message: 'Stack saved successfully' }); } catch (error) { console.error('Failed to save stack:', error); diff --git a/backend/src/services/CryptoService.ts b/backend/src/services/CryptoService.ts index a09db5d5..e794fa2d 100644 --- a/backend/src/services/CryptoService.ts +++ b/backend/src/services/CryptoService.ts @@ -17,6 +17,16 @@ export class CryptoService { if (fs.existsSync(keyPath)) { this.key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'hex'); + // Self-heal permissive file permissions (no-op on Windows) + try { + const mode = fs.statSync(keyPath).mode & 0o777; + if (mode !== 0o600) { + console.warn(`[CryptoService] Fixing permissive key file permissions (was 0o${mode.toString(8)}, set to 0o600)`); + fs.chmodSync(keyPath, 0o600); + } + } catch { + // chmod not supported on this platform (e.g. Windows) — skip + } } else { this.key = crypto.randomBytes(KEY_LENGTH); if (!fs.existsSync(dataDir)) { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index ccbd3c6b..6a8f9544 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -473,7 +473,7 @@ export class DatabaseService { this.db.prepare( 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)' ).run(username, passwordHash, 'admin', now, now); - console.log(`Migrated admin user "${username}" to users table.`); + console.log('Migrated legacy admin user to users table.'); } private migrateJsonConfig(dataDir: string) { diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index c842b017..f3b3f98b 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -85,10 +85,8 @@ export class FileSystemService { async saveStackContent(stackName: string, content: string): Promise { const filePath = path.join(this.baseDir, stackName, 'compose.yaml'); - console.log('Saving to path:', filePath); try { await fsPromises.writeFile(filePath, content, 'utf-8'); - console.log('File written successfully'); } catch (error) { console.error('Error writing file:', error); throw new Error(`Failed to save stack: ${stackName}`); @@ -128,10 +126,8 @@ export class FileSystemService { async saveEnvContent(stackName: string, content: string): Promise { const envPath = path.join(this.baseDir, stackName, '.env'); - console.log('Saving env to path:', envPath); try { await fsPromises.writeFile(envPath, content, 'utf-8'); - console.log('Env file written successfully'); } catch (error) { console.error('Error writing env file:', error); throw new Error(`Failed to save env file for stack: ${stackName}`); @@ -163,7 +159,6 @@ export class FileSystemService { `; try { await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8'); - console.log('Stack created successfully:', stackName); } catch (error) { console.error('Error creating stack:', error); throw new Error(`Failed to create stack: ${stackName}`); @@ -174,7 +169,6 @@ export class FileSystemService { const stackDir = path.join(this.baseDir, stackName); try { await fsPromises.rm(stackDir, { recursive: true, force: true }); - console.log('Stack deleted successfully:', stackName); } catch (error: unknown) { const fsError = error as NodeJS.ErrnoException; if (fsError.code === 'ENOENT') return; @@ -188,9 +182,8 @@ export class FileSystemService { try { await fsPromises.rmdir(stackDir); } catch { - console.warn(`[FileSystemService] Could not remove empty directory ${stackDir} — may need manual cleanup`); + console.warn('[FileSystemService] Could not remove empty directory after Docker fallback — may need manual cleanup'); } - console.log('Stack deleted successfully (via Docker fallback):', stackName); } else { console.error('Error deleting stack directory:', fsError.message); throw new Error(`Failed to delete stack directory: ${fsError.message}`); @@ -251,7 +244,6 @@ export class FileSystemService { try { await fsPromises.access(this.baseDir); } catch { - console.log('Creating compose directory:', this.baseDir); await fsPromises.mkdir(this.baseDir, { recursive: true }); return; } @@ -267,13 +259,11 @@ export class FileSystemService { try { await fsPromises.access(stackDir); - console.log(`Skipping migration for "${stackName}": directory already exists`); continue; } catch { // Directory doesn't exist, proceed } - console.log(`Migrating stack: ${stackName}`); await fsPromises.mkdir(stackDir, { recursive: true }); const oldComposePath = path.join(this.baseDir, item.name); @@ -285,12 +275,10 @@ export class FileSystemService { try { await fsPromises.access(oldEnvPath); await fsPromises.rename(oldEnvPath, newEnvPath); - console.log(`Migrated env file for: ${stackName}`); } catch { // No env file to migrate } - console.log(`Successfully migrated stack: ${stackName}`); } } catch (error) { console.error('Migration error:', error); diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 5026a8bd..50d9b745 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -24,6 +24,8 @@ if [ "$(id -u)" = '0' ]; then mkdir -p "$DATA_DIR" find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \ -exec chown sencho:sencho '{}' + + # Restrict encryption key to owner-only access (rw-------) + [ -f "$DATA_DIR/encryption.key" ] && chmod 600 "$DATA_DIR/encryption.key" echo "[entrypoint] Data directory ownership ensured: $DATA_DIR" # 2. Fix Docker socket group access. diff --git a/docs/images/rbac/user-create-form.png b/docs/images/rbac/user-create-form.png new file mode 100644 index 00000000..6112111a Binary files /dev/null and b/docs/images/rbac/user-create-form.png differ diff --git a/docs/reference/security-advisories.mdx b/docs/reference/security-advisories.mdx index be1b0ae4..aa93b1a0 100644 --- a/docs/reference/security-advisories.mdx +++ b/docs/reference/security-advisories.mdx @@ -7,6 +7,40 @@ Sencho's Docker image bundles the Docker CLI and Docker Compose plugin as static This page tracks known CVEs, their resolution status, and the minimum safe versions. +## April 2026 — Application Security Hardening + +A follow-up audit identified three application-level findings. All have been remediated in v0.25.2+. + +### 1. Encryption key file permissions + +**Finding:** The encryption key file (`encryption.key` in the data directory) was created with correct permissions (`0600`) on first run, but permissions were not verified on subsequent loads. If the file was copied, restored from backup, or had its permissions changed externally, it could remain world-readable. + +**Remediation:** +- The CryptoService now verifies file permissions on every startup and automatically corrects them to `0600` (owner read/write only) if they are more permissive. A warning is logged when this occurs. +- The Docker entrypoint now explicitly sets `chmod 600` on the encryption key before dropping privileges. + + + This is a defense-in-depth measure. In Docker deployments, the non-root `sencho` user and volume isolation already limit exposure. The fix primarily benefits bare-metal or non-containerized deployments. + + +### 2. Minimum password length increased to 8 characters + +**Finding:** The minimum password length was 6 characters, which is below the [NIST SP 800-63B](https://pages.nist.gov/800-63-3/sp800-63b.html) recommendation of at least 8 characters for memorized secrets. + +**Remediation:** The minimum password length has been increased from 6 to 8 characters across all validation points — initial setup, password changes, and user creation/updates. + + + Existing users with passwords shorter than 8 characters can still log in. The new minimum only applies when setting or changing a password. Administrators may want to notify users to update short passwords. + + +### 3. Sensitive data removed from console output + +**Finding:** Several `console.log` statements in the backend exposed full filesystem paths (including paths to `.env` files), stack names, and admin usernames in standard output. In containerized deployments, stdout is often collected by logging aggregators, making this an information disclosure risk. + +**Remediation:** All path-exposing and data-leaking log statements have been removed from `FileSystemService`, `DatabaseService`, and the stack management routes. Error-level logging (`console.error`) for failure diagnostics has been retained, but without sensitive path or identity details. + +--- + ## March 2026 Audit The following vulnerabilities were identified against the Sencho Docker image built with Docker CLI v29.3.1 and Docker Compose v2.40.3. diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0db67909..fc7c1ebd 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -175,8 +175,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { toast.error('New passwords do not match'); return; } - if (authData.newPassword.length < 6) { - toast.error('New password must be at least 6 characters'); + if (authData.newPassword.length < 8) { + toast.error('New password must be at least 8 characters'); return; } setIsSavingPassword(true); diff --git a/frontend/src/components/Setup.tsx b/frontend/src/components/Setup.tsx index 9f29152d..988b317d 100644 --- a/frontend/src/components/Setup.tsx +++ b/frontend/src/components/Setup.tsx @@ -35,8 +35,8 @@ export function Setup({ return; } - if (password.length < 6) { - setError('Password must be at least 6 characters'); + if (password.length < 8) { + setError('Password must be at least 8 characters'); return; } diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 19375e19..36a53dc3 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -78,8 +78,8 @@ export function UsersSection() { toast.error('Password is required for new users.'); return; } - if (formPassword && formPassword.length < 6) { - toast.error('Password must be at least 6 characters.'); + if (formPassword && formPassword.length < 8) { + toast.error('Password must be at least 8 characters.'); return; } if (formPassword && formPassword !== formConfirmPassword) { @@ -283,7 +283,7 @@ export function UsersSection() { type="password" value={formPassword} onChange={(e) => setFormPassword(e.target.value)} - placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} + placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'} />