diff --git a/lib/core/extensions/sandbox/sandbox_log_paths.dart b/lib/core/extensions/sandbox/sandbox_log_paths.dart new file mode 100644 index 00000000..344d0bba --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_log_paths.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Resolves sandbox log directories (Block E §6). +abstract final class SandboxLogPaths { + static const sandboxSegment = 'sandbox'; + static const logsSegment = 'logs'; + static const securityAuditFileName = 'security_audit.log'; + + @visibleForTesting + static Directory? mockLogsDirectory; + + /// `~/.local/share/Querya/logs` (or application support fallback / test mock). + static Future logsDirectory() async { + if (mockLogsDirectory != null) return mockLogsDirectory!; + + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home != null && home.isNotEmpty) { + if (Platform.isLinux) { + final xdg = Platform.environment['XDG_DATA_HOME']; + final base = (xdg != null && xdg.isNotEmpty) + ? xdg + : p.join(home, '.local', 'share'); + return Directory(p.join(base, 'Querya', logsSegment)); + } + if (Platform.isMacOS) { + return Directory( + p.join(home, 'Library', 'Application Support', 'Querya', logsSegment), + ); + } + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming'); + return Directory(p.join(appData, 'Querya', logsSegment)); + } + } + + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, logsSegment)); + } + + static Future ensureSandboxLogsDirectory() async { + final dir = Directory(p.join((await logsDirectory()).path, sandboxSegment)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future pluginLogFile(String pluginId) async { + final dir = await ensureSandboxLogsDirectory(); + return File(p.join(dir.path, '${_sanitizeId(pluginId)}.log')); + } + + static Future securityAuditLogFile() async { + final root = await logsDirectory(); + if (!await root.exists()) { + await root.create(recursive: true); + } + return File(p.join(root.path, securityAuditFileName)); + } + + static String _sanitizeId(String pluginId) { + final cleaned = pluginId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + if (cleaned.isEmpty) return 'plugin'; + return cleaned.length > 64 ? cleaned.substring(0, 64) : cleaned; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_rotating_log.dart b/lib/core/extensions/sandbox/sandbox_rotating_log.dart new file mode 100644 index 00000000..8a9bd87d --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_rotating_log.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Size-capped append-only log with simple rotation (Block E §6). +/// +/// When the active file would exceed [maxBytes], it is renamed to `*.log.1` +/// and a fresh file is opened. At most [maxFiles] files are kept +/// (active + archives). Issue #304: ≤ 2 files per plugin. +class SandboxRotatingLog { + SandboxRotatingLog({ + required this.file, + this.maxBytes = 5 * 1024 * 1024, + this.maxFiles = 2, + }) : assert(maxFiles >= 1); + + final File file; + final int maxBytes; + final int maxFiles; + + Future append(String text) async { + if (text.isEmpty) return; + await file.parent.create(recursive: true); + await _rotateIfNeeded(utf8.encode(text).length); + await file.writeAsString(text, mode: FileMode.append, flush: true); + } + + Future appendLine(String line) async { + final normalized = line.endsWith('\n') ? line : '$line\n'; + await append(normalized); + } + + Future _rotateIfNeeded(int incomingBytes) async { + if (!await file.exists()) return; + final size = await file.length(); + if (size + incomingBytes <= maxBytes) return; + + // Shift older archives up: .1 → .2 → … → .(maxFiles-1), drop the oldest. + for (var i = maxFiles - 1; i >= 2; i--) { + final src = File('${file.path}.${i - 1}'); + final dst = File('${file.path}.$i'); + if (await dst.exists()) { + await dst.delete(); + } + if (await src.exists()) { + await src.rename(dst.path); + } + } + + if (maxFiles == 1) { + await file.delete(); + return; + } + + final firstArchive = File('${file.path}.1'); + if (await firstArchive.exists()) { + await firstArchive.delete(); + } + await file.rename(firstArchive.path); + } + + static String archivePath(File active, int index) => + p.join(active.parent.path, '${p.basename(active.path)}.$index'); +} diff --git a/lib/core/extensions/sandbox/sandbox_sanitizer.dart b/lib/core/extensions/sandbox/sandbox_sanitizer.dart new file mode 100644 index 00000000..2b2bca53 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_sanitizer.dart @@ -0,0 +1,54 @@ +/// Redacts secrets from plugin log lines before they hit disk (Block E §6). +class SandboxSanitizer { + SandboxSanitizer._(); + + static const redactionToken = '[REDACTED BY SANDBOX]'; + + /// PEM private key blocks (including RSA / EC / OPENSSH variants). + static final _privateKey = RegExp( + r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----', + multiLine: true, + ); + + /// Compact JWT (header.payload.signature). + static final _jwt = RegExp( + r'\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b', + ); + + /// Connection URIs with an embedded password (`scheme://user:pass@host`). + static final _uriWithPassword = RegExp( + r'\b([a-zA-Z][a-zA-Z0-9+.-]*://[^/\s:@]+):([^@\s]+)@', + ); + + /// Common password / token assignment forms in dumps. + static final _passwordAssignment = RegExp( + r'''\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\b(\s*[:=]\s*)(["']?)([^\s"'&,;]+)(["']?)''', + caseSensitive: false, + ); + + /// Authorization bearer headers. + static final _bearer = RegExp( + r'\b(authorization\s*:\s*bearer\s+)\S+', + caseSensitive: false, + ); + + /// Sanitizes a single chunk / line of plugin output. + static String sanitize(String input) { + if (input.isEmpty) return input; + var out = input; + out = out.replaceAll(_privateKey, redactionToken); + out = out.replaceAll(_jwt, redactionToken); + out = out.replaceAllMapped(_uriWithPassword, (m) { + return '${m[1]}:$redactionToken@'; + }); + out = out.replaceAllMapped(_passwordAssignment, (m) { + final quote = m[3] ?? ''; + final endQuote = m[5] ?? ''; + return '${m[1]}${m[2]}$quote$redactionToken$endQuote'; + }); + out = out.replaceAllMapped(_bearer, (m) { + return '${m[1]}$redactionToken'; + }); + return out; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_security_audit.dart b/lib/core/extensions/sandbox/sandbox_security_audit.dart new file mode 100644 index 00000000..a6ea9e74 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_security_audit.dart @@ -0,0 +1,58 @@ +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; + +/// Categories recorded in `security_audit.log`. +enum SandboxSecurityEventType { + filesystemEscape('filesystem_escape'), + memoryQuotaExceeded('memory_quota_exceeded'), + forbiddenNetworkHost('forbidden_network_host'), + secretLeakBlocked('secret_leak_blocked'), + deadlock('deadlock'), + other('other'); + + const SandboxSecurityEventType(this.value); + final String value; +} + +/// Append-only security audit journal for sandbox policy violations. +class SandboxSecurityAudit { + SandboxSecurityAudit({SandboxRotatingLog? log}) : _log = log; + + SandboxRotatingLog? _log; + + /// Max size for the audit log (10 MB, keep 2 files). + static const maxBytes = 10 * 1024 * 1024; + + Future _ensureLog() async { + final existing = _log; + if (existing != null) return existing; + final file = await SandboxLogPaths.securityAuditLogFile(); + return _log = SandboxRotatingLog( + file: file, + maxBytes: maxBytes, + maxFiles: 2, + ); + } + + Future record({ + required SandboxSecurityEventType type, + required String pluginId, + String? detail, + DateTime? at, + }) async { + final timestamp = (at ?? DateTime.now().toUtc()).toIso8601String(); + final line = StringBuffer() + ..write(timestamp) + ..write('\t') + ..write(type.value) + ..write('\t') + ..write(pluginId); + if (detail != null && detail.isNotEmpty) { + line + ..write('\t') + ..write(detail.replaceAll('\n', ' ').replaceAll('\t', ' ')); + } + final log = await _ensureLog(); + await log.appendLine(line.toString()); + } +} diff --git a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart new file mode 100644 index 00000000..e678c660 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; + +/// Captures `process.stderr`, sanitizes it, and writes to a rotating log. +class SandboxStderrPipe { + SandboxStderrPipe({ + required this.pluginId, + required this.log, + this.audit, + this.onSanitizedLine, + }); + + final String pluginId; + final SandboxRotatingLog log; + final SandboxSecurityAudit? audit; + final void Function(String line)? onSanitizedLine; + + StreamSubscription>? _subscription; + final StringBuffer _carry = StringBuffer(); + Future _writeChain = Future.value(); + var _closed = false; + + bool get isAttached => _subscription != null && !_closed; + + /// Creates a pipe for [handle] writing to the standard sandbox log path. + static Future attach( + SandboxProcessHandle handle, { + SandboxSecurityAudit? audit, + int maxBytes = 5 * 1024 * 1024, + int maxFiles = 2, + void Function(String line)? onSanitizedLine, + }) async { + final file = await SandboxLogPaths.pluginLogFile(handle.pluginId); + final pipe = SandboxStderrPipe( + pluginId: handle.pluginId, + log: SandboxRotatingLog( + file: file, + maxBytes: maxBytes, + maxFiles: maxFiles, + ), + audit: audit, + onSanitizedLine: onSanitizedLine, + ); + pipe.listen(handle.process.stderr); + return pipe; + } + + /// Starts consuming [stderr]. Safe to call once. + void listen(Stream> stderr) { + if (_subscription != null) { + throw StateError('SandboxStderrPipe already attached'); + } + _subscription = stderr.listen( + _onBytes, + onError: (Object e, StackTrace st) { + debugPrint('SandboxStderrPipe($pluginId) stderr error: $e'); + }, + onDone: () { + _writeChain = _writeChain.then((_) => _flushCarry()); + }, + cancelOnError: false, + ); + } + + Future close() async { + if (_closed) return; + _closed = true; + await _subscription?.cancel(); + _subscription = null; + await _writeChain; + await _flushCarry(); + } + + void _onBytes(List chunk) { + if (chunk.isEmpty) return; + _carry.write(utf8.decode(chunk, allowMalformed: true)); + _drainLines(); + } + + void _drainLines() { + final text = _carry.toString(); + final parts = text.split('\n'); + _carry.clear(); + if (!text.endsWith('\n')) { + _carry.write(parts.removeLast()); + } else if (parts.isNotEmpty && parts.last.isEmpty) { + parts.removeLast(); + } + + for (final raw in parts) { + _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + } + } + + Future _flushCarry() async { + if (_carry.isEmpty) return; + final raw = _carry.toString(); + _carry.clear(); + await _writeSanitized(raw); + } + + Future _writeSanitized(String raw) async { + try { + final sanitized = SandboxSanitizer.sanitize(raw); + if (sanitized != raw && audit != null) { + await audit!.record( + type: SandboxSecurityEventType.secretLeakBlocked, + pluginId: pluginId, + detail: 'stderr redaction applied', + ); + } + onSanitizedLine?.call(sanitized); + await log.appendLine(sanitized); + } catch (e, st) { + debugPrint('SandboxStderrPipe($pluginId) write failed: $e\n$st'); + } + } +} diff --git a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart new file mode 100644 index 00000000..5b514a47 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_stderr_pipe.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stderrController = StreamController>.broadcast() { + stdin = IOSink(StreamController>().sink); + } + + final StreamController> _stderrController; + final _exit = Completer(); + + void emitStderr(String text) { + _stderrController.add(utf8.encode(text)); + } + + Future closeStderr() => _stderrController.close(); + + @override + int get pid => 7; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => const Stream.empty(); + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + if (!_exit.isCompleted) _exit.complete(0); + return true; + } +} + +void main() { + group('SandboxSanitizer', () { + test('redacts JWT, URI passwords, PEM keys, and assignments', () { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.signature'; + const pem = ''' +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7 +-----END PRIVATE KEY----- +'''; + final input = [ + 'token=$jwt', + 'dsn=postgres://alice:hunter2@db.example:5432/app', + 'password: super-secret', + 'Authorization: Bearer abc.def.ghi', + pem, + ].join('\n'); + + final out = SandboxSanitizer.sanitize(input); + expect(out, isNot(contains('hunter2'))); + expect(out, isNot(contains('super-secret'))); + expect(out, isNot(contains(jwt))); + expect(out, isNot(contains('BEGIN PRIVATE KEY'))); + expect(out, contains(SandboxSanitizer.redactionToken)); + expect(out, contains('postgres://alice:${SandboxSanitizer.redactionToken}@')); + }); + + test('leaves benign lines untouched', () { + const line = 'INFO connected to host=db.example port=5432'; + expect(SandboxSanitizer.sanitize(line), line); + }); + }); + + group('SandboxRotatingLog', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_rotlog_'); + }); + + tearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('rotates when exceeding maxBytes and keeps at most 2 files', () async { + final file = File(p.join(temp.path, 'plugin.log')); + final log = SandboxRotatingLog( + file: file, + maxBytes: 64, + maxFiles: 2, + ); + + await log.append('a' * 50); + await log.append('b' * 50); + + expect(await file.exists(), isTrue); + final archive = File('${file.path}.1'); + expect(await archive.exists(), isTrue); + expect(await archive.readAsString(), 'a' * 50); + expect(await file.readAsString(), 'b' * 50); + + await log.append('c' * 50); + expect(await archive.readAsString(), 'b' * 50); + expect(await file.readAsString(), 'c' * 50); + expect(await File('${file.path}.2').exists(), isFalse); + }); + }); + + group('SandboxSecurityAudit', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_audit_'); + SandboxLogPaths.mockLogsDirectory = temp; + }); + + tearDown(() async { + SandboxLogPaths.mockLogsDirectory = null; + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('writes tab-separated incidents to security_audit.log', () async { + final audit = SandboxSecurityAudit(); + await audit.record( + type: SandboxSecurityEventType.forbiddenNetworkHost, + pluginId: 'test.driver', + detail: '169.254.169.254', + at: DateTime.utc(2026, 7, 10, 12), + ); + + final file = await SandboxLogPaths.securityAuditLogFile(); + final body = await file.readAsString(); + expect(body, contains('forbidden_network_host')); + expect(body, contains('test.driver')); + expect(body, contains('169.254.169.254')); + expect(body, startsWith('2026-07-10T12:00:00.000Z')); + }); + }); + + group('SandboxStderrPipe', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_stderr_'); + SandboxLogPaths.mockLogsDirectory = temp; + }); + + tearDown(() async { + SandboxLogPaths.mockLogsDirectory = null; + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('sanitizes stderr and writes rotating plugin log', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.driver', + baseDirectory: temp, + token: '1', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.driver', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final audit = SandboxSecurityAudit(); + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + audit: audit, + onSanitizedLine: lines.add, + ); + + process.emitStderr('password=leak-me\n'); + process.emitStderr('ok line\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, hasLength(2)); + expect(lines[0], contains(SandboxSanitizer.redactionToken)); + expect(lines[0], isNot(contains('leak-me'))); + expect(lines[1], 'ok line'); + + final logFile = await SandboxLogPaths.pluginLogFile('pipe.driver'); + final body = await logFile.readAsString(); + expect(body, contains(SandboxSanitizer.redactionToken)); + expect(body, contains('ok line')); + expect(body, isNot(contains('leak-me'))); + + final auditBody = + await (await SandboxLogPaths.securityAuditLogFile()).readAsString(); + expect(auditBody, contains('secret_leak_blocked')); + + await handle.dispose(); + }); + }); +}