fix(security): harden encryption key permissions, increase password minimum, remove sensitive logs (#323)

Self-heal encryption key file permissions to 0600 on startup. Increase
minimum password length from 6 to 8 characters per NIST SP 800-63B.
Remove console.log statements that exposed file paths, .env locations,
stack names, and admin usernames to stdout.
This commit is contained in:
Anso
2026-04-01 21:27:37 -04:00
committed by GitHub
parent 1c221508a2
commit f317a83814
11 changed files with 67 additions and 31 deletions
+3
View File
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### 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 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:** 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 * **security:** add 1-year default expiry to node proxy JWT tokens — previously issued without expiry
+9 -10
View File
@@ -47,6 +47,7 @@ const _origEmitWarning = process.emitWarning.bind(process);
_origEmitWarning(warning, ...args); _origEmitWarning(warning, ...args);
}; };
const MIN_PASSWORD_LENGTH = 8;
const app = express(); const app = express();
const PORT = 3000; const PORT = 3000;
@@ -353,8 +354,8 @@ app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response)
return; return;
} }
if (password.length < 6) { if (password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: 'Password must be at least 6 characters' }); res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return; 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' }); res.status(400).json({ error: 'Old password and new password are required' });
return; return;
} }
if (newPassword.length < 6) { if (newPassword.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: 'New password must be at least 6 characters' }); res.status(400).json({ error: `New password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return; 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)' }); res.status(400).json({ error: 'Username must be at least 3 characters (letters, numbers, underscore, hyphen)' });
return; return;
} }
if (typeof password !== 'string' || password.length < 6) { if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: 'Password must be at least 6 characters' }); res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return; return;
} }
const validRoles: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; 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 (password !== undefined) {
if (typeof password !== 'string' || password.length < 6) { if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
res.status(400).json({ error: 'Password must be at least 6 characters' }); res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters` });
return; return;
} }
updates.password_hash = await bcrypt.hash(password, 10); updates.password_hash = await bcrypt.hash(password, 10);
@@ -2491,13 +2492,11 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
} }
try { try {
const { content } = req.body; const { content } = req.body;
console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length });
if (typeof content !== 'string') { if (typeof content !== 'string') {
console.error('Content is not a string:', content); console.error('Content is not a string:', content);
return res.status(400).json({ error: 'Content must be a string' }); return res.status(400).json({ error: 'Content must be a string' });
} }
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
console.log('Stack saved successfully:', stackName);
res.json({ message: 'Stack saved successfully' }); res.json({ message: 'Stack saved successfully' });
} catch (error) { } catch (error) {
console.error('Failed to save stack:', error); console.error('Failed to save stack:', error);
+10
View File
@@ -17,6 +17,16 @@ export class CryptoService {
if (fs.existsSync(keyPath)) { if (fs.existsSync(keyPath)) {
this.key = Buffer.from(fs.readFileSync(keyPath, 'utf-8').trim(), 'hex'); 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 { } else {
this.key = crypto.randomBytes(KEY_LENGTH); this.key = crypto.randomBytes(KEY_LENGTH);
if (!fs.existsSync(dataDir)) { if (!fs.existsSync(dataDir)) {
+1 -1
View File
@@ -473,7 +473,7 @@ export class DatabaseService {
this.db.prepare( this.db.prepare(
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)' 'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
).run(username, passwordHash, 'admin', now, now); ).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) { private migrateJsonConfig(dataDir: string) {
+1 -13
View File
@@ -85,10 +85,8 @@ export class FileSystemService {
async saveStackContent(stackName: string, content: string): Promise<void> { async saveStackContent(stackName: string, content: string): Promise<void> {
const filePath = path.join(this.baseDir, stackName, 'compose.yaml'); const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
console.log('Saving to path:', filePath);
try { try {
await fsPromises.writeFile(filePath, content, 'utf-8'); await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) { } catch (error) {
console.error('Error writing file:', error); console.error('Error writing file:', error);
throw new Error(`Failed to save stack: ${stackName}`); throw new Error(`Failed to save stack: ${stackName}`);
@@ -128,10 +126,8 @@ export class FileSystemService {
async saveEnvContent(stackName: string, content: string): Promise<void> { async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env'); const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath);
try { try {
await fsPromises.writeFile(envPath, content, 'utf-8'); await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) { } catch (error) {
console.error('Error writing env file:', error); console.error('Error writing env file:', error);
throw new Error(`Failed to save env file for stack: ${stackName}`); throw new Error(`Failed to save env file for stack: ${stackName}`);
@@ -163,7 +159,6 @@ export class FileSystemService {
`; `;
try { try {
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8'); await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
console.log('Stack created successfully:', stackName);
} catch (error) { } catch (error) {
console.error('Error creating stack:', error); console.error('Error creating stack:', error);
throw new Error(`Failed to create stack: ${stackName}`); throw new Error(`Failed to create stack: ${stackName}`);
@@ -174,7 +169,6 @@ export class FileSystemService {
const stackDir = path.join(this.baseDir, stackName); const stackDir = path.join(this.baseDir, stackName);
try { try {
await fsPromises.rm(stackDir, { recursive: true, force: true }); await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName);
} catch (error: unknown) { } catch (error: unknown) {
const fsError = error as NodeJS.ErrnoException; const fsError = error as NodeJS.ErrnoException;
if (fsError.code === 'ENOENT') return; if (fsError.code === 'ENOENT') return;
@@ -188,9 +182,8 @@ export class FileSystemService {
try { try {
await fsPromises.rmdir(stackDir); await fsPromises.rmdir(stackDir);
} catch { } 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 { } else {
console.error('Error deleting stack directory:', fsError.message); console.error('Error deleting stack directory:', fsError.message);
throw new Error(`Failed to delete stack directory: ${fsError.message}`); throw new Error(`Failed to delete stack directory: ${fsError.message}`);
@@ -251,7 +244,6 @@ export class FileSystemService {
try { try {
await fsPromises.access(this.baseDir); await fsPromises.access(this.baseDir);
} catch { } catch {
console.log('Creating compose directory:', this.baseDir);
await fsPromises.mkdir(this.baseDir, { recursive: true }); await fsPromises.mkdir(this.baseDir, { recursive: true });
return; return;
} }
@@ -267,13 +259,11 @@ export class FileSystemService {
try { try {
await fsPromises.access(stackDir); await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`);
continue; continue;
} catch { } catch {
// Directory doesn't exist, proceed // Directory doesn't exist, proceed
} }
console.log(`Migrating stack: ${stackName}`);
await fsPromises.mkdir(stackDir, { recursive: true }); await fsPromises.mkdir(stackDir, { recursive: true });
const oldComposePath = path.join(this.baseDir, item.name); const oldComposePath = path.join(this.baseDir, item.name);
@@ -285,12 +275,10 @@ export class FileSystemService {
try { try {
await fsPromises.access(oldEnvPath); await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath); await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`);
} catch { } catch {
// No env file to migrate // No env file to migrate
} }
console.log(`Successfully migrated stack: ${stackName}`);
} }
} catch (error) { } catch (error) {
console.error('Migration error:', error); console.error('Migration error:', error);
+2
View File
@@ -24,6 +24,8 @@ if [ "$(id -u)" = '0' ]; then
mkdir -p "$DATA_DIR" mkdir -p "$DATA_DIR"
find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \ find "$DATA_DIR" \( \! -user sencho -o \! -group sencho \) \
-exec chown sencho: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" echo "[entrypoint] Data directory ownership ensured: $DATA_DIR"
# 2. Fix Docker socket group access. # 2. Fix Docker socket group access.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+34
View File
@@ -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. 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.
<Note>
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.
</Note>
### 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.
<Note>
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.
</Note>
### 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 ## 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. The following vulnerabilities were identified against the Sencho Docker image built with Docker CLI v29.3.1 and Docker Compose v2.40.3.
+2 -2
View File
@@ -175,8 +175,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
toast.error('New passwords do not match'); toast.error('New passwords do not match');
return; return;
} }
if (authData.newPassword.length < 6) { if (authData.newPassword.length < 8) {
toast.error('New password must be at least 6 characters'); toast.error('New password must be at least 8 characters');
return; return;
} }
setIsSavingPassword(true); setIsSavingPassword(true);
+2 -2
View File
@@ -35,8 +35,8 @@ export function Setup({
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
setError('Password must be at least 6 characters'); setError('Password must be at least 8 characters');
return; return;
} }
@@ -78,8 +78,8 @@ export function UsersSection() {
toast.error('Password is required for new users.'); toast.error('Password is required for new users.');
return; return;
} }
if (formPassword && formPassword.length < 6) { if (formPassword && formPassword.length < 8) {
toast.error('Password must be at least 6 characters.'); toast.error('Password must be at least 8 characters.');
return; return;
} }
if (formPassword && formPassword !== formConfirmPassword) { if (formPassword && formPassword !== formConfirmPassword) {
@@ -283,7 +283,7 @@ export function UsersSection() {
type="password" type="password"
value={formPassword} value={formPassword}
onChange={(e) => setFormPassword(e.target.value)} onChange={(e) => setFormPassword(e.target.value)}
placeholder={editingUser ? 'Leave blank to keep' : 'min. 6 characters'} placeholder={editingUser ? 'Leave blank to keep' : 'min. 8 characters'}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">