// Preview-Variablen: Verschluesselung (AES-256-GCM), Validierung, Log-Maskierung import crypto from 'node:crypto' const NAME_RE = /^[A-Z_][A-Z0-9_]*$/ const MAX_VARS = 30 const MAX_BYTES = 4096 // Schluessel aus PREVIEW_ENV_KEY (base64, genau 32 Byte) oder null export function envKey() { const raw = process.env.PREVIEW_ENV_KEY if (!raw) return null const buf = Buffer.from(raw, 'base64') return buf.length === 32 ? buf : null } export function encryptEnv(obj, key) { const iv = crypto.randomBytes(12) const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) const ct = Buffer.concat([cipher.update(JSON.stringify(obj || {}), 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() return ['v1', iv, tag, ct].map((p) => (typeof p === 'string' ? p : p.toString('base64'))).join(':') } export function decryptEnv(str, key) { if (!str) return {} const parts = String(str).split(':') if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('Ungueltiges Variablenformat') const [, iv, tag, ct] = parts.map((p, i) => (i === 0 ? p : Buffer.from(p, 'base64'))) if (iv.length !== 12 || tag.length !== 16) throw new Error('Ungueltiges Variablenformat') const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) decipher.setAuthTag(tag) const plain = Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') const obj = JSON.parse(plain) if (!obj || typeof obj !== 'object' || Array.isArray(obj) || !Object.values(obj).every((v) => typeof v === 'string')) { throw new Error('Ungueltiges Variablenformat') } return obj } export function envNames(str, key) { return Object.keys(decryptEnv(str, key)).sort() } export function validateEnvChange(current, { set = {}, remove = [] } = {}) { const env = { ...(current || {}) } for (const name of remove || []) delete env[name] for (const [name, value] of Object.entries(set || {})) { if (!NAME_RE.test(name)) { return { ok: false, error: `Ungültiger Variablenname „${name}“ (erlaubt: Großbuchstaben, Ziffern, Unterstrich; nicht mit Ziffer beginnen).` } } if (typeof value !== 'string') { return { ok: false, error: `Der Wert von ${name} muss Text sein.` } } if (Buffer.byteLength(value, 'utf8') > MAX_BYTES) { return { ok: false, error: `Der Wert von ${name} ist zu lang (maximal ${MAX_BYTES} Byte).` } } if (/[\0\r\n]/.test(value)) { return { ok: false, error: `Der Wert von ${name} darf keine Zeilenumbrüche oder Nullzeichen enthalten.` } } env[name] = value } if (Object.keys(env).length > MAX_VARS) { return { ok: false, error: `Zu viele Variablen (maximal ${MAX_VARS}).` } } return { ok: true, env } } // Werte (>= 4 Zeichen) im Protokoll durch ••• ersetzen, laengste zuerst export function maskLog(text, env) { let out = String(text ?? '') const values = Object.values(env || {}) .filter((v) => typeof v === 'string' && v.length >= 4) .sort((a, b) => b.length - a.length) for (const v of values) out = out.split(v).join('•••') return out }