'use strict'; const fs = require('node:fs/promises'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); const { createZip } = require('../shared/zip-writer.cjs'); const { sanitizeForDiagnostics, redactSecrets } = require('./log-redaction.cjs'); const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 }; function dateKey(value = new Date()) { return value.toISOString().slice(0, 10); } function byteSizeLabel(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`; } function safeJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } function auditBundleEntries(entries, secrets = []) { const candidates = [...new Set((secrets || []).map((item) => String(item || '').trim()).filter((item) => item.length >= 4))]; const findings = []; for (const entry of entries) { const text = Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data ?? ''); for (const secret of candidates) { if (text.includes(secret)) findings.push({ file: entry.name, type: 'known-runtime-secret' }); } if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i.test(text)) findings.push({ file: entry.name, type: 'private-key-marker' }); if (/https?:\/\/[^\s:@/]+:(?!\[REDACTED\])[^@\s/]+@/i.test(text)) findings.push({ file: entry.name, type: 'url-credential' }); } return { passed: findings.length === 0, checkedFiles: entries.length, knownRuntimeSecretCount: candidates.length, findings }; } class DiagnosticsService { constructor({ userDataPath, appInfo = {}, secretProvider = () => [], preferencesProvider = () => ({}) }) { this.userDataPath = userDataPath; this.logDirectory = path.join(userDataPath, 'diagnostics'); this.appInfo = appInfo; this.secretProvider = secretProvider; this.preferencesProvider = preferencesProvider; this.sessionId = crypto.randomUUID(); this.writeChain = Promise.resolve(); this.pendingLines = []; this.pendingFlush = null; this.securedFiles = new Set(); this.initialized = false; this.lastWriteError = null; this.lastBundlePath = null; } preferences() { const source = this.preferencesProvider?.() || {}; return { enabled: source.diagnosticsEnabled !== false, level: ['debug', 'info', 'warning', 'error'].includes(source.diagnosticLevel) ? source.diagnosticLevel : 'info', retentionDays: Math.min(Math.max(Number(source.logRetentionDays) || 14, 1), 90), maxFileMb: Math.min(Math.max(Number(source.maxLogFileMb) || 8, 1), 50) }; } sanitize(value, options = {}) { return sanitizeForDiagnostics(value, { secrets: this.secretProvider?.() || [], homeDir: os.homedir(), cwd: process.cwd(), ...options }); } async initialize() { await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); try { await fs.chmod(this.logDirectory, 0o700); } catch {} this.initialized = true; await this.prune(); await this.info('diagnostics.session.started', { sessionId: this.sessionId, app: this.appInfo, platform: process.platform, arch: process.arch, node: process.versions.node, electron: process.versions.electron || null }); } shouldWrite(level) { const preferences = this.preferences(); return preferences.enabled && LEVELS[level] >= LEVELS[preferences.level]; } filePathForToday() { return path.join(this.logDirectory, `forgeflow-${dateKey()}.jsonl`); } async rotateIfNeeded(filePath) { const limit = this.preferences().maxFileMb * 1024 * 1024; const stat = await fs.stat(filePath).catch(() => null); if (!stat || stat.size < limit) return filePath; for (let index = 1; index < 100; index += 1) { const candidate = path.join(this.logDirectory, `forgeflow-${dateKey()}-${String(index).padStart(2, '0')}.jsonl`); const candidateStat = await fs.stat(candidate).catch(() => null); if (!candidateStat || candidateStat.size < limit) return candidate; } return path.join(this.logDirectory, `forgeflow-${dateKey()}-${Date.now()}.jsonl`); } log(level, event, details = {}) { if (!this.shouldWrite(level)) return Promise.resolve(false); const record = this.sanitize({ timestamp: new Date().toISOString(), level, event: String(event || 'diagnostics.event').slice(0, 160), sessionId: this.sessionId, details }); this.pendingLines.push(`${JSON.stringify(record)}\n`); // At the debug level every IPC call and every Gitea request writes a line. // Records that queue up while a write is in flight are appended together, so // a burst costs one open/write/close instead of one per record. if (this.pendingFlush) return this.pendingFlush; this.pendingFlush = this.writeChain.then(async () => { this.pendingFlush = null; const lines = this.pendingLines.splice(0).join(''); if (!lines) return true; try { if (!this.initialized) await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); const target = await this.rotateIfNeeded(this.filePathForToday()); await fs.appendFile(target, lines, { encoding: 'utf8', mode: 0o600 }); // The mode above only applies when appendFile creates the file, so the // explicit chmod is needed once per file rather than once per record. if (!this.securedFiles.has(target)) { try { await fs.chmod(target, 0o600); } catch { /* best effort */ } this.securedFiles.add(target); } this.lastWriteError = null; return true; } catch (error) { this.lastWriteError = error.message; return false; } }); this.writeChain = this.pendingFlush.catch(() => {}); return this.pendingFlush; } debug(event, details) { return this.log('debug', event, details); } info(event, details) { return this.log('info', event, details); } warning(event, details) { return this.log('warning', event, details); } error(event, details) { return this.log('error', event, details); } async flush() { await this.writeChain; } async listLogFiles() { await fs.mkdir(this.logDirectory, { recursive: true, mode: 0o700 }); const entries = await fs.readdir(this.logDirectory, { withFileTypes: true }); const files = []; for (const entry of entries) { if (!entry.isFile() || !/^forgeflow-.*\.jsonl$/i.test(entry.name)) continue; const absolute = path.join(this.logDirectory, entry.name); const stat = await fs.stat(absolute).catch(() => null); if (stat) files.push({ name: entry.name, path: absolute, size: stat.size, modifiedAt: stat.mtime.toISOString() }); } return files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)); } async prune() { const cutoff = Date.now() - this.preferences().retentionDays * 24 * 60 * 60 * 1000; for (const file of await this.listLogFiles()) { if (new Date(file.modifiedAt).getTime() < cutoff) await fs.rm(file.path, { force: true }).catch(() => {}); } } async getStatus() { await this.flush(); const files = await this.listLogFiles(); const totalBytes = files.reduce((sum, file) => sum + file.size, 0); return { enabled: this.preferences().enabled, level: this.preferences().level, retentionDays: this.preferences().retentionDays, maxFileMb: this.preferences().maxFileMb, directory: this.sanitize(this.logDirectory), fileCount: files.length, totalBytes, totalSize: byteSizeLabel(totalBytes), latestAt: files[0]?.modifiedAt || null, lastWriteError: this.lastWriteError }; } async clear() { await this.flush(); for (const file of await this.listLogFiles()) await fs.rm(file.path, { force: true }); await this.info('diagnostics.logs.cleared', {}); return this.getStatus(); } async collectLogs(maxBytes = 20 * 1024 * 1024) { await this.flush(); const output = []; let used = 0; for (const file of await this.listLogFiles()) { if (used >= maxBytes) break; const remaining = maxBytes - used; const content = await fs.readFile(file.path); const slice = content.length > remaining ? content.subarray(content.length - remaining) : content; output.push({ name: `logs/${file.name}`, data: Buffer.from(redactSecrets(slice.toString('utf8'), this.secretProvider?.() || []), 'utf8') }); used += slice.length; } return output; } async exportSupportBundle({ destinationPath, publicState, repositories = [], operations = [], preflight = null, privacyMode = 'standard', extra = {} }) { if (!destinationPath) throw new Error('No support bundle destination was selected.'); if (!['standard', 'strict'].includes(privacyMode)) throw new Error('Unsupported diagnostic privacy mode.'); if (path.extname(destinationPath).toLowerCase() !== '.zip') throw new Error('Diagnostic bundles must use the .zip extension.'); await this.info('diagnostics.bundle.requested', { privacyMode, repositoryCount: repositories.length, operationCount: operations.length }); const strict = privacyMode === 'strict'; const sanitize = (value) => this.sanitize(value, { strictIdentifiers: strict }); const generatedAt = new Date().toISOString(); const diagnosticsStatus = await this.getStatus(); const system = sanitize({ app: this.appInfo, generatedAt, sessionId: this.sessionId, platform: process.platform, arch: process.arch, release: os.release(), type: os.type(), cpus: os.cpus()?.map((cpu) => cpu.model).filter((value, index, array) => array.indexOf(value) === index), cpuCount: os.cpus()?.length || null, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), uptimeSeconds: os.uptime(), locale: Intl.DateTimeFormat().resolvedOptions().locale, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, versions: process.versions }); const sanitizedState = sanitize(publicState || {}); if (sanitizedState.gitea) sanitizedState.gitea.hasToken = Boolean(publicState?.gitea?.hasToken); const sanitizedRepositories = sanitize(repositories.map((repository) => ({ id: repository.id, fullName: repository.fullName, linkState: repository.linkState, localPath: repository.localPath, attention: repository.attention, attentionReason: repository.attentionReason, readyToDeploy: repository.readyToDeploy, localStatus: repository.localStatus ? { branch: repository.localStatus.branch, head: repository.localStatus.head, counts: repository.localStatus.counts, clean: repository.localStatus.clean, remoteUrl: repository.localStatus.remoteUrl } : null, deploymentProfiles: repository.deploymentProfiles?.map((profile) => ({ id: profile.id, name: profile.name, environment: profile.environment, branch: profile.branch, workflowFile: profile.workflowFile, rollbackWorkflowFile: profile.rollbackWorkflowFile, healthcheckUrl: profile.healthcheckUrl, statusUrl: profile.statusUrl, state: profile.state })) || [] }))); const sanitizedOperations = sanitize(operations.map((operation) => ({ id: operation.id, type: operation.type, action: operation.action, status: operation.status, repository: operation.repository, profileId: operation.profileId, profileName: operation.profileName, environment: operation.environment, workflowFile: operation.workflowFile, branch: operation.branch, sha: operation.sha, shortSha: operation.shortSha, createdAt: operation.createdAt, updatedAt: operation.updatedAt, dispatchedAt: operation.dispatchedAt, stages: operation.stages, jobs: operation.jobs, logs: operation.logs, failure: operation.failure, pollError: operation.pollError, applicationState: operation.applicationState, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, name: operation.run.name, status: operation.run.status, conclusion: operation.run.conclusion, headSha: operation.run.headSha, headBranch: operation.run.headBranch, workflowPath: operation.run.workflowPath, createdAt: operation.run.createdAt, updatedAt: operation.run.updatedAt } : null, runnerLog: operation.runnerLog ? { included: false, reason: 'Raw runner output is intentionally omitted from diagnostic bundles.', characters: String(operation.runnerLog).length, lines: String(operation.runnerLog).split(/\r?\n/).length } : null }))); const manifest = { schemaVersion: 1, product: 'ForgeFlow Support Bundle', generatedAt, privacyMode, containsSecrets: false, redaction: { knownRuntimeSecrets: true, sensitiveObjectKeys: true, authorizationHeaders: true, credentialUrls: true, privateKeys: true, userHomePaths: true, identifiersHashed: strict }, files: [] }; const entries = [ { name: 'README.txt', data: `ForgeFlow diagnostic support bundle\nGenerated: ${generatedAt}\nPrivacy mode: ${privacyMode}\n\nThis bundle is generated locally. Access tokens, passwords, authorization headers, embedded URL credentials, encrypted token blobs and private keys are removed. Review the bundle before sharing it.\n` }, { name: 'system.json', data: safeJson(system) }, { name: 'diagnostics-status.json', data: safeJson(sanitize(diagnosticsStatus)) }, { name: 'configuration-sanitized.json', data: safeJson(sanitizedState) }, { name: 'repositories-sanitized.json', data: safeJson(sanitizedRepositories) }, { name: 'operations-sanitized.json', data: safeJson(sanitizedOperations) }, { name: 'preflight.json', data: safeJson(sanitize(preflight || {})) }, { name: 'context.json', data: safeJson(sanitize(extra || {})) }, ...(await this.collectLogs()) ]; const safetyAudit = auditBundleEntries(entries, this.secretProvider?.() || []); if (!safetyAudit.passed) { await this.error('diagnostics.bundle.safety-check-failed', { findings: safetyAudit.findings }); throw new Error('The diagnostic bundle failed its local secret-safety check and was not written.'); } entries.push({ name: 'safety-audit.json', data: safeJson(safetyAudit) }); manifest.files = entries.map((entry) => ({ name: entry.name, bytes: Buffer.byteLength(entry.data) })); entries.unshift({ name: 'manifest.json', data: safeJson(manifest) }); const archive = createZip(entries); const temporary = `${destinationPath}.${process.pid}.${Date.now()}.tmp`; await fs.mkdir(path.dirname(destinationPath), { recursive: true }); await fs.writeFile(temporary, archive, { mode: 0o600 }); await fs.rename(temporary, destinationPath); try { await fs.chmod(destinationPath, 0o600); } catch {} this.lastBundlePath = path.resolve(destinationPath); const sha256 = crypto.createHash('sha256').update(archive).digest('hex'); await this.info('diagnostics.bundle.created', { destinationPath, bytes: archive.length, sha256, privacyMode }); return { path: destinationPath, bytes: archive.length, size: byteSizeLabel(archive.length), sha256, privacyMode, generatedAt }; } isKnownBundlePath(filePath) { return Boolean(filePath && this.lastBundlePath && path.resolve(filePath) === this.lastBundlePath); } } module.exports = { DiagnosticsService, dateKey, byteSizeLabel, auditBundleEntries, LEVELS };