Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions lib/core/extensions/sandbox/sandbox_log_paths.dart
Original file line number Diff line number Diff line change
@@ -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<Directory> 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<Directory> ensureSandboxLogsDirectory() async {
final dir = Directory(p.join((await logsDirectory()).path, sandboxSegment));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}

static Future<File> pluginLogFile(String pluginId) async {
final dir = await ensureSandboxLogsDirectory();
return File(p.join(dir.path, '${_sanitizeId(pluginId)}.log'));
}

static Future<File> 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;
}
}
65 changes: 65 additions & 0 deletions lib/core/extensions/sandbox/sandbox_rotating_log.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> appendLine(String line) async {
final normalized = line.endsWith('\n') ? line : '$line\n';
await append(normalized);
}

Future<void> _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');
}
54 changes: 54 additions & 0 deletions lib/core/extensions/sandbox/sandbox_sanitizer.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
58 changes: 58 additions & 0 deletions lib/core/extensions/sandbox/sandbox_security_audit.dart
Original file line number Diff line number Diff line change
@@ -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<SandboxRotatingLog> _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<void> 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());
}
}
125 changes: 125 additions & 0 deletions lib/core/extensions/sandbox/sandbox_stderr_pipe.dart
Original file line number Diff line number Diff line change
@@ -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<List<int>>? _subscription;
final StringBuffer _carry = StringBuffer();
Future<void> _writeChain = Future<void>.value();
var _closed = false;

bool get isAttached => _subscription != null && !_closed;

/// Creates a pipe for [handle] writing to the standard sandbox log path.
static Future<SandboxStderrPipe> 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<List<int>> 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<void> close() async {
if (_closed) return;
_closed = true;
await _subscription?.cancel();
_subscription = null;
await _writeChain;
await _flushCarry();
}

void _onBytes(List<int> 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<void> _flushCarry() async {
if (_carry.isEmpty) return;
final raw = _carry.toString();
_carry.clear();
await _writeSanitized(raw);
}

Future<void> _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');
}
}
}
Loading
Loading